Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -14,7 +14,7 @@ from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import hidden_keys
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
|
||||
|
||||
STRUCTURES_FILE_NAME = "structures.json"
|
||||
_STAGE_DIR = os.path.join("B05_Profile", "route")
|
||||
|
||||
@@ -42,7 +42,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
|
||||
@@ -19,8 +20,6 @@ const MIN_CHART_HEIGHT = 100;
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
|
||||
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */
|
||||
const HEIGHT_KEY = stateKey("profile-height") ?? "";
|
||||
@@ -147,7 +146,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
);
|
||||
},
|
||||
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
|
||||
max: () => profilePanelMaxHeight(root),
|
||||
storageKey: HEIGHT_KEY,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
|
||||
/** 패널 높이 상한(px) — 메인 리사이저 max 와 자동 확대가 같이 쓴다. 기준 = 한 페이지 가운데 칸
|
||||
* `.design-page__main`(바로 위 `.design-page__profile` 은 display: contents 라 높이 0). */
|
||||
export function profilePanelMaxHeight(root: HTMLElement): number {
|
||||
const basis = root.closest<HTMLElement>(".design-page__main") ?? root.parentElement;
|
||||
return (basis?.clientHeight || window.innerHeight) * MAX_PANEL_HEIGHT_RATIO;
|
||||
}
|
||||
|
||||
export interface HeightCascadeDeps {
|
||||
/** 패널 루트 — 자리가 모자라면 이 높이를 키운다. */
|
||||
root: HTMLElement;
|
||||
@@ -155,9 +162,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
|
||||
const growCap = Math.round(
|
||||
(deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
);
|
||||
const growCap = Math.round(profilePanelMaxHeight(deps.root));
|
||||
const grown = Math.min(deps.root.offsetHeight + deficit, growCap);
|
||||
if (grown > deps.root.offsetHeight + 1) {
|
||||
deps.root.style.setProperty("--b05-profile-height", `${grown}px`);
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface TemplateDoc {
|
||||
/** 서버가 판 불일치로 막았을 때 */
|
||||
export class StaleError extends Error {}
|
||||
|
||||
async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
/** `/api/m02` 아래 길 부름 — 409 = StaleError · 그 밖 실패 = 서버 까닭 글(하위 폴더도 씀) */
|
||||
export async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -192,30 +193,6 @@ export interface StructureCombos {
|
||||
export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/structure-combos");
|
||||
|
||||
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
|
||||
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
/** 거짓이면 B05 선택지에 안 나오는 종류 */
|
||||
enabled: boolean;
|
||||
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
|
||||
}
|
||||
|
||||
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
|
||||
export interface OptionsDoc {
|
||||
판: string;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자)
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
|
||||
/** 항목 하나 복사해 가져오기(39-5 · 39-1b) — 받는 열(`column`)에 덮어씀 · 번호 · 열은 그대로 ·
|
||||
* `source` = 원본 열 id(실무문서면 조합 키) · `from` 없음 = 지금 층 · 층 · 주인 = 다른 층(원본 읽기 권한) ·
|
||||
* `practice` = 실무문서 조합 → 일위대가(시스템 관리자만) */
|
||||
|
||||
@@ -21,13 +21,25 @@
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
/* 이름 칸(45-7) — 칸은 표 칸 그대로(flex 면 칸 선이 끊김) · 코드 · 이름 · 설명은 글마다 한 줄 말줄임(전체 = title) */
|
||||
.m02-labrow__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.m02-labrow__name > * {
|
||||
display: block;
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m02-labrow th,
|
||||
.m02-labrow__code,
|
||||
.m02-labrow__spec {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-labrow__key,
|
||||
.m02-labrow__note,
|
||||
.m02-labrow__muted {
|
||||
@@ -129,9 +141,9 @@
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* 총합계 줄 전체 합계 칸(44-6) — 테두리 · 색 */
|
||||
/* 총합계 줄 전체 합계 칸(44-6) — 테두리 · 색 · 테두리는 안쪽 그림자(칸 선 굵기 안 바꿈 · 45-7) */
|
||||
.m02-labrow__sum .m02-labrow__total {
|
||||
border: 2px solid var(--color-primary);
|
||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@@ -232,6 +244,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__name > * {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.m02-labrow .m02-labrow__no,
|
||||
.m02-labrow .m02-labrow__unit:not([data-label]),
|
||||
.m02-labrow .m02-labrow__code,
|
||||
|
||||
@@ -135,6 +135,12 @@ function moneyCell(
|
||||
});
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
};
|
||||
|
||||
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap",
|
||||
@@ -240,30 +246,32 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: row.로직 }),
|
||||
el("strong", { text: logic?.이름 ?? row.로직 }),
|
||||
...(values.empty
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
|
||||
...(why
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: `${tc("Labor_Calc_Fail")}: ${why}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: row.로직 }),
|
||||
el("strong", { text: logic?.이름 ?? row.로직 }),
|
||||
...(values.empty
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
|
||||
...(why
|
||||
? [
|
||||
el("small", {
|
||||
className: "m02-labrow__empty",
|
||||
text: `${tc("Labor_Calc_Fail")}: ${why}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
),
|
||||
aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null),
|
||||
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", { className: "m02-labrow__unit", text: unit }),
|
||||
@@ -382,16 +390,22 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__code", text: mat.코드 }),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: mat.이름 ?? "" }),
|
||||
...(line?.까닭
|
||||
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
|
||||
: []),
|
||||
],
|
||||
className: "m02-labrow__spec",
|
||||
text: mat.규격 ?? "",
|
||||
attrs: { title: mat.규격 ?? "" },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }),
|
||||
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as options
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
@@ -21,11 +20,6 @@ class SaveBody(BaseModel):
|
||||
문서: Any
|
||||
|
||||
|
||||
class OptionsBody(BaseModel):
|
||||
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
|
||||
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
@@ -55,18 +49,6 @@ def post_structure(column: str) -> dict:
|
||||
return _call(store.create_structure, column)
|
||||
|
||||
|
||||
@router.get("/structure-options")
|
||||
def get_structure_options() -> dict:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@router.put("/structure-options")
|
||||
def put_structure_options(body: OptionsBody) -> dict:
|
||||
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
|
||||
return _call(options.save, body.표시, body.판)
|
||||
|
||||
|
||||
# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로
|
||||
@router.post("/structures/{column}/copy")
|
||||
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
|
||||
|
||||
@@ -288,14 +288,6 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen
|
||||
return _call(_root(session, layer, owner), store.structure_numbers)
|
||||
|
||||
|
||||
@router.get("/layers/system/structure-options")
|
||||
def get_structure_options(session: Session = Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as options
|
||||
|
||||
return _call(_root(session, "system"), options.listing)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/structure-combos")
|
||||
def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
|
||||
return _call(_root(session, layer, owner), store.read_combos)
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type TemplateInfo,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { buildOptionsSection } from "./StructOptions/M02_SO_Options";
|
||||
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
|
||||
@@ -143,13 +143,6 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 구조물 옵션 보이기 — 종류 제목 */
|
||||
.m02-options__head {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 층 화면 — 회사용 읽기만 안내 막대 · 회사 없음 안내 */
|
||||
.m02-layer-bar {
|
||||
display: flex;
|
||||
@@ -172,76 +165,6 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
|
||||
.m02-opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
max-width: 64rem;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__panes {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 20rem) 1fr;
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-opts__kinds,
|
||||
.m02-opts__list {
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-opts__group {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__kind {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__kind:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__kind.is-active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__option {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.m02-main__head + .m02-tree__search {
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Api.ts
|
||||
* 구조물 옵션 보이기 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물옵션표시.json`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { call } from "../M02_MasterTemplete_Api_Fetch";
|
||||
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
/** 거짓이면 B05 선택지에 안 나오는 종류 */
|
||||
enabled: boolean;
|
||||
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
|
||||
}
|
||||
|
||||
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
|
||||
export interface OptionsDoc {
|
||||
판: string;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
|
||||
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
+6
-5
@@ -1,20 +1,21 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Options.ts
|
||||
* M02_SO_Options.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
|
||||
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
|
||||
* 서버 = `M02_SO_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
fetchStructureOptions,
|
||||
saveStructureOptions,
|
||||
StaleError,
|
||||
type OptionKind,
|
||||
type OptionsDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { to } from "./M02_MasterTemplete_Options_Text";
|
||||
} from "./M02_SO_Api";
|
||||
import "./M02_SO_Style.css";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
export interface OptionsSectionOptions {
|
||||
isAdmin: boolean;
|
||||
@@ -0,0 +1,52 @@
|
||||
"""M02 구조물 옵션 보이기 길 — 정본 `M02_SO_Options.py`.
|
||||
|
||||
`router` = 고쳐 씀 · 관리 읽기(`main.py` 가 `system_admin_only` 로 붙임) ·
|
||||
`read_router` = 읽기만(로그인 누구나 · `main.py` 가 `protected` 로 붙임).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
read_router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
|
||||
|
||||
class OptionsBody(BaseModel):
|
||||
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
|
||||
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
with store.at(store.FOLDER):
|
||||
try:
|
||||
return fn(*args)
|
||||
except store.StoreError as e:
|
||||
raise HTTPException(status_code=e.status, detail=e.detail) from e
|
||||
|
||||
|
||||
@router.get("/structure-options")
|
||||
def get_structure_options() -> dict:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@router.put("/structure-options")
|
||||
def put_structure_options(body: OptionsBody) -> dict:
|
||||
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
|
||||
return _call(options.save, body.표시, body.판)
|
||||
|
||||
|
||||
@read_router.get("/layers/system/structure-options")
|
||||
def get_structure_options_read(session=Depends(verify_session)) -> dict:
|
||||
"""구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 시스템 관리자만)."""
|
||||
access.require(session, "system", None)
|
||||
return _call(options.listing)
|
||||
@@ -0,0 +1,76 @@
|
||||
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
|
||||
.m02-opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
max-width: 64rem;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 종류 제목 */
|
||||
.m02-options__head {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-opts__panes {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 20rem) 1fr;
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-opts__kinds,
|
||||
.m02-opts__list {
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-opts__group {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__kind {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__kind:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__kind.is-active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__option {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Options_Text.ts
|
||||
* M02_SO_Text.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -25,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Router as so_router
|
||||
from resources.tester.helper_m02_master_sample import copy_master_sample
|
||||
from resources.tester.test_item_copy_complete import ( # noqa: F401 — 복사본 world 고정물
|
||||
FOLDER,
|
||||
@@ -64,7 +65,7 @@ def empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
gone = empty_master(system)
|
||||
session = {"user_id": 42, "company_id": 7, "role": "SYSTEM_ADMIN", "is_master": True}
|
||||
app = FastAPI()
|
||||
for module in (main_router, items_router, layers_router):
|
||||
for module in (main_router, items_router, layers_router, so_router):
|
||||
app.include_router(module.router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return {"client": TestClient(app), "system": system, "gone": gone}
|
||||
|
||||
@@ -18,9 +18,9 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as opt
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as opt
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
URL = "/api/m02/structure-options"
|
||||
@@ -131,12 +131,12 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에
|
||||
assert "matset" not in side
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
|
||||
api = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Api.ts").read_text("utf-8")
|
||||
assert (
|
||||
'call("/layers/system/structure-options")' in api
|
||||
and 'call("/structure-options", { method: "PUT"' in api
|
||||
)
|
||||
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8")
|
||||
ui = (ROOT / "M02_MasterTemplete/StructOptions/M02_SO_Options.ts").read_text("utf-8")
|
||||
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Router as so_router
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
SYS = "/api/m02/layers/system"
|
||||
@@ -39,6 +40,7 @@ def world(tmp_path, monkeypatch) -> dict[str, Any]:
|
||||
app = FastAPI()
|
||||
app.include_router(items_router.router)
|
||||
app.include_router(layers_router.router)
|
||||
app.include_router(so_router.read_router)
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
return {"client": TestClient(app), "session": session}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/* 45-7 — M02 구조물 조합 · 일위대가 표(단가산출 로직 · 자재목록) 이름 한 줄 · 칸 선 이어짐 — 카드 · 상세 두 곳
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
|
||||
* 나무에 item1(구조물 조합 · 일위대가 로직 담김)이 있음
|
||||
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌 · 줄바꿈 있으면 orca 가 끊김)
|
||||
* 하는 일: item1 카드 표를 잼 → 일위대가 카드 [수정]으로 상세를 열어 잼 → item1 을 다시 눌러 카드 화면으로 돌아감(값 안 바꿈)
|
||||
* 기대 수치(카드 · 상세 각각):
|
||||
* - nameLines = 1(이름 칸 · 코드 · 규격 칸 글마다 줄 수 최대) · headLines = 1(머리 칸 글 줄 수 최대)
|
||||
* - spanBad = 0(각 줄 칸 수 = 머리 칸 수 · colspan 셈)
|
||||
* - broken = 0(칸 위·아래가 줄 위·아래와 어긋남 · 한 줄 안 아래 테두리 굵기가 다름 · 칸 사이 틈)
|
||||
* - ellipsis = 이름 글에 말줄임 꼴(text-overflow ellipsis) · titled = 이름 글마다 title
|
||||
* - 카드는 가로 스크롤 그대로(cardScroll = true · 표 폭 > 감싼 칸 폭)
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 8000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const lines = (e) => {
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(e);
|
||||
const tops = [...r.getClientRects()].filter((q) => q.width > 0).map((q) => Math.round(q.top));
|
||||
return new Set(tops).size;
|
||||
};
|
||||
const span = (tr) => [...tr.cells].reduce((a, c) => a + (c.colSpan || 1), 0);
|
||||
const measure = (t) => {
|
||||
const head = span(t.tHead.rows[0]);
|
||||
const body = [...t.querySelectorAll("tbody tr, tfoot tr")];
|
||||
const texts = [
|
||||
...t.querySelectorAll(
|
||||
".m02-labrow__name > *, tbody .m02-labrow__code, tbody .m02-labrow__spec",
|
||||
),
|
||||
...t.querySelectorAll(".m02-labrow__name:not(:has(*))"),
|
||||
].filter((e) => e.textContent.trim());
|
||||
const kids = [...t.querySelectorAll(".m02-labrow__name > *")];
|
||||
let broken = 0;
|
||||
const why = [];
|
||||
for (const tr of body) {
|
||||
const R = tr.getBoundingClientRect();
|
||||
const cells = [...tr.cells].filter((c) => c.getClientRects().length);
|
||||
const widths = new Set(cells.map((c) => getComputedStyle(c).borderBottomWidth));
|
||||
if (widths.size > 1) (broken++, why.push(`굵기 ${tr.className} ${[...widths]}`));
|
||||
cells.forEach((c, i) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
if (Math.abs(r.top - R.top) > 1 || Math.abs(r.bottom - R.bottom) > 1)
|
||||
(broken++,
|
||||
why.push(`높이 ${tr.className} ${c.className} ${Math.round(r.bottom - R.bottom)}`));
|
||||
const n = cells[i + 1];
|
||||
if (n && Math.abs(n.getBoundingClientRect().left - r.right) > 1)
|
||||
(broken++, why.push(`틈 ${tr.className} ${c.className}`));
|
||||
});
|
||||
}
|
||||
const wrap = t.parentElement;
|
||||
return {
|
||||
head,
|
||||
rows: body.length,
|
||||
spanBad: body.filter((tr) => span(tr) !== head).length,
|
||||
nameLines: Math.max(0, ...texts.map(lines)),
|
||||
headLines: Math.max(0, ...[...t.tHead.rows[0].cells].map(lines)),
|
||||
broken,
|
||||
why: why.slice(0, 6),
|
||||
ellipsis: kids.every((e) => getComputedStyle(e).textOverflow === "ellipsis"),
|
||||
titled: kids.every((e) => e.title === e.textContent),
|
||||
scroll: Math.round(t.getBoundingClientRect().width) > Math.round(wrap.clientWidth),
|
||||
};
|
||||
};
|
||||
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
|
||||
if (!node) return JSON.stringify({ error: "item1 없음 — 기슭막이 폴더를 펼쳐 둘 것" });
|
||||
node.click();
|
||||
const card = await until(() =>
|
||||
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
|
||||
c.querySelector("table.m02-labrow"),
|
||||
),
|
||||
);
|
||||
if (!card) return JSON.stringify({ error: "일위대가 카드 표 없음" });
|
||||
await w(1500);
|
||||
const out = { card: [...card.querySelectorAll("table.m02-labrow")].map(measure) };
|
||||
out.cardScroll = out.card[0]?.scroll ?? null;
|
||||
const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find(
|
||||
(b) => b.innerText.trim() === "수정",
|
||||
);
|
||||
if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" });
|
||||
edit.click();
|
||||
await until(() =>
|
||||
[...document.querySelectorAll("table.m02-labrow")].find((t) => !t.closest(".m02-struct__card")),
|
||||
);
|
||||
await w(2000);
|
||||
out.detail = [...document.querySelectorAll("table.m02-labrow")]
|
||||
.filter((t) => !t.closest(".m02-struct__card"))
|
||||
.map(measure);
|
||||
/* 카드 화면으로 돌아감 */
|
||||
document.querySelector('[data-node="i|item1"]')?.click();
|
||||
await w(1000);
|
||||
out.back = !!document.querySelector(".m02-struct__card table.m02-labrow");
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user