Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7

This commit is contained in:
2026-11-01 15:31:56 +09:00
8 changed files with 278 additions and 14 deletions
+1
View File
@@ -64,6 +64,7 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
m01("m01", L("M01_Title"), read),
m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"),
m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"),
m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"),
m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
+237
View File
@@ -0,0 +1,237 @@
/* =============================================================================
* M01_MasterData_UI_Alias.ts
* M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만.
* · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음
* (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 ·
* [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창
* · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움]
* · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createTable, type Column } from "@ui/ui_template_table";
import { createTabs } from "@ui/ui_template_tabs";
import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음
import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
import {
buildHopyoLedger,
KIND_TEXT,
LEDGER_HINT,
pairText,
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger";
export const ALIAS_TITLE = "별칭 장부";
const KIND_NAME: Record<string, string> = { 맞춤: "일위대가", 내역맞춤: "공사 내역" };
interface Origin {
품명: string;
규격: string;
단위: string;
}
export interface AliasCandidate {
종류: "맞춤" | "내역맞춤";
열쇠: string;
서명: string;
원래: Origin;
갈래?: string;
짝?: LedgerPair[];
확정?: string;
이름?: string;
회사: number;
사람: number;
때: string | null;
충돌: boolean;
마스터: "다름" | null;
}
interface MasterBill {
열쇠: string;
원래: Origin;
확정: string;
이름?: string;
}
export interface AliasBoard {
후보: AliasCandidate[];
모은곳: { 회사: number; 사람: number };
반려: number;
마스터내역?: MasterBill[];
}
async function call<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const got = (await res.json()) as T & { detail?: unknown };
if (!res.ok) throw new Error(String(got.detail ?? `HTTP ${res.status}`));
return got;
}
/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */
export const candidateText = (c: AliasCandidate): string =>
c.종류 === "내역맞춤"
? `${c.이름 ?? ""} (${c.확정 ?? ""})`
: c.갈래 === "짝"
? (c.짝 ?? []).map(pairText).join(" + ")
: (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? "");
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 });
const CANDIDATE_COLS: Column[] = [
{ label: "종류", width: "84px" },
{ label: "엑셀 품명" },
{ label: "규격" },
{ label: "단위", width: "64px" },
{ label: "짝" },
{ label: "회사", width: "56px", align: "end" },
{ label: "사람", width: "56px", align: "end" },
{ label: "충돌", width: "96px" },
{ label: "", width: "136px" },
];
const BILL_COLS: Column[] = [
{ label: "엑셀 품명" },
{ label: "규격" },
{ label: "단위", width: "64px" },
{ label: "우리 공종" },
{ label: "", width: "72px" },
];
export function buildAliasLedger(): HTMLElement {
const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } });
const body = el("div", { className: "m01-alias__body" });
let view = "candidates";
async function act(c: AliasCandidate, ok: boolean): Promise<void> {
const name = `${c.원래.품명} ${c.원래.규격}`.trim();
const yes = await askConfirm({
title: ok ? "마스터 장부에 승인" : "후보 반려",
message: ok
? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).`
: `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`,
okLabel: ok ? "승인" : "반려",
danger: !ok,
});
if (!yes) return;
try {
const got = await call<AliasBoard>(ok ? "/approve" : "/reject", pick(c));
showToast(ok ? "마스터 장부에 넣음" : "반려함", "success");
paintCandidates(got);
} catch (error) {
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
}
}
function candidateRow(c: AliasCandidate): (string | Node)[] {
const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : "";
return [
KIND_NAME[c.종류] ?? c.종류,
c.원래.품명,
c.원래.규격,
c.원래.단위,
candidateText(c),
String(c.회사),
String(c.사람),
clash ? createBadge(clash, "warning") : "",
el("div", {
className: "ui-toolbar",
children: [
createButton({
label: "승인",
variant: "filled",
size: "sm",
onClick: () => void act(c, true),
}),
createButton({
label: "반려",
variant: "ghost",
size: "sm",
onClick: () => void act(c, false),
}),
],
}),
];
}
function paintCandidates(board: AliasBoard): void {
const head = createPanelHead({
title: "모인 후보",
sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`,
});
body.replaceChildren(
head.root,
createTable(CANDIDATE_COLS, board.후보.map(candidateRow), {
density: "data",
empty: "모인 후보 없음",
}),
);
}
async function forget(b: MasterBill): Promise<void> {
const yes = await askConfirm({
title: "마스터 공사 내역 맞춤 지움",
message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`,
okLabel: "지움",
danger: true,
});
if (!yes) return;
try {
const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 });
showToast("지움", "success");
paintMaster(got.마스터내역);
} catch (error) {
showToast(`지우지 못 함 — ${why(error)}`, "error");
}
}
function paintMaster(bill: MasterBill[]): void {
const rows = bill.map((b) => [
b.원래.품명,
b.원래.규격,
b.원래.단위,
`${b.이름 ?? ""} (${b.확정})`,
createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }),
]);
body.replaceChildren(
buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }),
createPanelHead({
title: "마스터 공사 내역 맞춤",
sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)",
}).root,
createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }),
);
}
async function load(): Promise<void> {
const stop = showLoading(body);
try {
const got = await call<AliasBoard>("");
if (view === "candidates") paintCandidates(got);
else paintMaster(got.마스터내역 ?? []);
} catch (error) {
body.replaceChildren(loadingError(why(error), () => void load()));
} finally {
stop();
}
}
const tabs = createTabs({
tabs: [
{ key: "candidates", label: "모인 후보" },
{ key: "master", label: "마스터 장부" },
],
value: view,
onChange: (key) => {
view = key;
void load();
},
});
root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김)
void load();
return root;
}
+3 -2
View File
@@ -2,7 +2,8 @@
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`)
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 주소 `?open=로직|옵션` = 그 칸을 고른 채 열림(716-2)
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 장부(718-3) ·
* 주소 `?open=로직|옵션|별칭` = 그 칸을 고른 채 열림(716-2)
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
* 자동저장 없음.
@@ -333,6 +334,6 @@ function buildPage(): HTMLElement {
if (hasPendingLogicKey()) openLogicTab();
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
const want = currentQuery().get("open");
if (want === "로직" || want === "옵션") side.reveal(want);
if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);
return layout;
}
+20 -3
View File
@@ -3,6 +3,7 @@
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`)
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
* ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기)
* · 별칭 장부(718-3 · 누르면 본 칸에 모인 후보 · 마스터 장부 · `M01_MasterData_UI_Alias`)
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
* ========================================================================== */
@@ -14,6 +15,7 @@ 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";
@@ -110,7 +112,7 @@ export interface SideHandle {
/** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */
filter: (args: FilterArgs) => HTMLElement;
/** 주소 `open` — 그 컨테이너를 펼치고 고름(로직 = 로직 목록 · 옵션 = 정의 화면까지) */
reveal: (target: "로직" | "옵션") => void;
reveal: (target: "로직" | "옵션" | "별칭") => void;
/** 옵션 줄 고름 표시 지움(다른 화면을 열었음) · 켬(떠나기를 취소해 머묾) */
setOptionsMark: (on: boolean) => void;
}
@@ -166,6 +168,7 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const ALIAS_ID = "별칭|";
export function buildSide(
onOpen: (pick: Pick) => void,
@@ -500,9 +503,22 @@ ${first.file}`
// 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능)
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, createDivider(), optionsSec],
children: [...groups, logic, createDivider(), optionsSec, aliasSec],
});
attachCollapsible(root);
void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) =>
@@ -514,7 +530,8 @@ ${first.file}`
// 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만
const files = (group: string): FileInfo[] =>
group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []);
const reveal = (target: "로직" | "옵션"): void => {
const reveal = (target: "로직" | "옵션" | "별칭"): void => {
if (target === "별칭") return void openAlias();
if (target === "옵션") {
optionsSec.classList.remove("is-collapsed");
options.body.querySelector<HTMLElement>("button")?.click();
@@ -102,3 +102,7 @@ B05 평면도 / D그룹 예정노선의 지도의 아이콘 셋 그룹화/위치
## 요구 553 (11-01 · 사용자 · 데스크탑 서브에 시킬 일 검토)
데스크탑 서브 워크트리에 시킬 작업하나 검토 해보자. 지금 라이다 데이터를 입력할때 사용하는 로직이 내부 자체 코드로 구현되었어. 혹시 QGIS나 무료로 사용하는 프로그램 중에 사용할 수 있는게 뭐가 있는지 조사해보자. 자체코드도 좋지만 품질문제가 조금 있는것같아서 말이야.
## 요구 554 (11-01 · 사용자 · 553 라이다 조사 아티팩트 보고 뒤)
라이다관련 확인했고 내가 보기에는 그냥 단순히 많은 점이 누락되거나 조금 어긋난다고 해서 잘못되었다고 보기 힘들어. 스무딩이나 평균처리하는 과정에서 결국 데이터는 조정이 되거든. 그래서 현재의 로직은 그냥 두고 외부라이브러리나 프로그램을 활용하여 결과를 보고 판단하는게 좋을것같아. 데스크탑 서브만을 이용해서 해당 워크트리에 깃 이그너 폴더가 포함된 폴더를 추가하고 해당폴더에 시험용 라이브러리와 프로그램을 설치해서 테스트하고 결과를 보고 판단하자. 아티팩트로 공유해줘.
@@ -49,15 +49,16 @@ def test_줄_해시_옵션은_M01():
from test_b01_settings_cards import _cards
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:6]]
heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]]
assert heads == [
"m01-master-data?open=로직",
"m01-master-data?open=옵션",
"m01-master-data?open=별칭", # 718-3
"m02-master-template?open=일위대가조합",
"m02-master-template?open=구조물조합",
"m02-master-template?open=도면",
]
assert all("&root=master" in c[2] for c in cards[3:6])
assert all("&root=master" in c[2] for c in cards[4:7])
@needs_node
@@ -175,9 +176,9 @@ def test_옛_옵션_주소_M01로_돌려보냄():
def test_M01_open_인자_로직_옵션():
m01 = ROOT / "M01_MasterData"
page = _text(m01 / "M01_MasterData_UI_Page.ts")
assert 'if (want === "로직" || want === "옵션") side.reveal(want);' in page
assert 'if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);' in page
assert "setLeaveGuard(leaveOptions)" in page # 저장 안 한 정의 고침 — 떠날 때 물음
side = _text(m01 / "M01_MasterData_UI_Side.ts")
assert "buildOptionsSection({ ...optionsOpt, isAdmin: true })" in side
# 로직 칸 아래 구분선 부품 + 옵션 칸
assert "logic, createDivider(), optionsSec" in side
assert "logic, createDivider(), optionsSec, aliasSec" in side # 718-3 별칭 장부 칸
+7 -5
View File
@@ -97,20 +97,22 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_줄_칸별_일곱_고른_회사_해시():
# 716-2 — 대시보드 설계 기본값 = 칸별 줄: M01 3(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) +
def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시():
# 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 ·
# 별칭 장부) +
# M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["M01_Title", READ_SRC, M01],
["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"],
["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"],
["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"],
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"],
["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
]
# 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[4][2] == (
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == (
f"{M02}?open=구조물조합&root=master"
)
@@ -241,7 +243,7 @@ def test_구조물_조합_기본_층_개인():
134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙."""
combo = f"{M02}?open=구조물조합&root=personal"
rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3)
assert rows[3:6] == [
assert rows[4:7] == [
["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"],
["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"],
["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"],
@@ -252,7 +254,7 @@ def test_구조물_조합_기본_층_개인():
assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo
assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo
# 개인 층 없음(회사 없음) = 지금 규칙
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[4][2] == (
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == (
f"{M02}?open=구조물조합&root=master"
)
assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == (
+1
View File
@@ -173,4 +173,5 @@ export const ui_locales_m1 = {
B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"],
B01_Dashboard_CardLogic: ["단가산출 로직", "Cost-logic library"],
B01_Dashboard_CardStructOptionDef: ["구조물 옵션 정의", "Structure option definition"],
B01_Dashboard_CardAliasLedger: ["별칭 장부", "Alias ledger"],
} as const;