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

This commit is contained in:
2026-09-25 09:49:11 +09:00
23 changed files with 1121 additions and 410 deletions
@@ -0,0 +1,66 @@
/* M02 도면 양식 편집 부품 — 위 도구 줄(작도 영역 · 자리표 · 단추) + 아래 웹캐드. */
.m02-drawing {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
height: 100%;
min-height: 0;
}
.m02-drawing > .cad-host {
flex: 1 1 0;
}
.m02-drawing__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16, 16px);
}
.m02-drawing__title {
align-self: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
}
.m02-drawing__area {
display: flex;
align-items: flex-end;
gap: var(--spacing-8);
}
.m02-drawing__area .ui-field {
width: 5.5rem;
}
.m02-drawing__fields {
display: flex;
flex: 1 1 20rem;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4, 4px);
}
.m02-drawing__field {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 1440px;
background-color: var(--color-surface);
color: var(--color-text);
font-size: 0.72rem;
cursor: pointer;
}
.m02-drawing__field[data-source="도면"] {
color: var(--color-text-muted);
}
.m02-drawing__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
@@ -0,0 +1,220 @@
/* =============================================================================
* M02_MasterTemplete_Drawing.ts
* M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3).
*
* 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})`
* → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다.
* `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다.
*
* 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` ·
* `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다.
* ========================================================================== */
import "./M02_MasterTemplete_Drawing.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
/** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */
export interface DrawingTemplateDoc extends CadHostDrawing {
format?: number;
source?: string;
drawing_area?: [number, number, number, number];
[key: string]: unknown;
}
export interface DrawingTemplateOptions {
/** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean;
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
name?: string;
}
export interface DrawingTemplateHandle {
getDoc: () => Promise<DrawingTemplateDoc>;
destroy: () => void;
}
/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
interface DrawingField {
key: string;
source: string;
label: string;
}
async function fetchDrawingFields(): Promise<DrawingField[]> {
const response = await fetch(`${API_BASE_URL}/m02/drawing-fields`, { credentials: "include" });
const payload = (await response.json()) as { fields?: DrawingField[]; message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload.fields ?? [];
}
async function importDrawingFile(
file: File,
): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> {
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, {
method: "POST",
credentials: "include",
body: form,
});
const payload = (await response.json()) as {
drawing: DrawingTemplateDoc;
entity_count: number;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function mountDrawingTemplate(
container: HTMLElement,
doc: DrawingTemplateDoc,
options: DrawingTemplateOptions = {},
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
const recoveryScope = `m02:${options.name ?? "drawing"}`;
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
root.className = "m02-drawing";
const toolbar = document.createElement("div");
toolbar.className = "m02-drawing__toolbar";
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다.
const area = document.createElement("div");
area.className = "m02-drawing__area";
const areaTitle = document.createElement("span");
areaTitle.className = "m02-drawing__title";
areaTitle.textContent = "작도 영역";
const start = doc.drawing_area ?? DEFAULT_AREA;
const areaInputs = AREA_LABELS.map((label, index) => {
const field = createInputField({ label, type: "number", value: String(start[index]) });
field.input.disabled = readOnly;
return field;
});
area.append(areaTitle, ...areaInputs.map((field) => field.root));
const readArea = (): [number, number, number, number] | null => {
const values = areaInputs.map((field) => Number(field.input.value));
const valid = values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3];
areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위"));
return valid ? (values as [number, number, number, number]) : null;
};
// 자리표 키 — 누르면 `{{키}}` 를 복사한다. 도각 글자에 붙여 넣으면 그릴 때 값이 채워진다.
const fields = document.createElement("div");
fields.className = "m02-drawing__fields";
const fieldsTitle = document.createElement("span");
fieldsTitle.className = "m02-drawing__title";
fieldsTitle.textContent = "자리표";
fields.append(fieldsTitle);
void fetchDrawingFields()
.then((list) => {
for (const field of list) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "m02-drawing__field";
chip.dataset.source = field.source;
chip.textContent = `{{${field.key}}}`;
chip.title = `${field.label} (${field.source}) — 눌러 복사`;
chip.addEventListener("click", () => {
void navigator.clipboard
?.writeText(`{{${field.key}}}`)
.then(() => showToast(`{{${field.key}}} 를 복사했습니다.`, "success"));
});
fields.append(chip);
}
})
.catch((error) =>
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
);
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading();
cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
};
const actions = document.createElement("div");
actions.className = "m02-drawing__actions";
if (!readOnly) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".dxf,.dwg";
fileInput.hidden = true;
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
fileInput.value = "";
if (!file) return;
importButton.disabled = true;
importDrawingFile(file)
.then((response) => {
// 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다.
load(response.drawing);
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
})
.catch((error) =>
showToast(
error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.",
"error",
),
)
.finally(() => (importButton.disabled = false));
});
const importButton = createButton({
label: "파일 불러오기",
variant: "ghost",
onClick: () => fileInput.click(),
});
actions.append(importButton, fileInput);
}
const getDoc = async (): Promise<DrawingTemplateDoc> => {
const { drawing } = await cad.requestSave();
const drawingArea = readArea();
if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다.");
base = { ...base, ...drawing, drawing_area: drawingArea };
return base;
};
if (options.onSave && !readOnly) {
const onSave = options.onSave;
const saveButton = createButton({
label: "저장",
variant: "filled",
onClick: () => {
saveButton.disabled = true;
getDoc()
.then((next) => onSave(next))
.catch((error) =>
showToast(
error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
"error",
),
)
.finally(() => (saveButton.disabled = false));
},
});
actions.append(saveButton);
}
toolbar.append(area, fields, actions);
root.append(toolbar, cad.element);
container.append(root);
load(doc);
return {
getDoc,
destroy: () => {
cad.destroy();
root.remove();
},
};
}
@@ -0,0 +1,95 @@
"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3).
양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다.
여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다.
"""
import asyncio
import logging
from uuid import UUID
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import JSONResponse
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_document,
validate_template_entities,
)
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
_IMPORT_MAX_BYTES = 20 * 1024 * 1024
# 자리표 `{{키}}` 로 쓸 수 있는 키 — 도면을 그릴 때 채워지는 것만 둔다.
# 프로젝트 값은 B07 `title_block_fields` 가 · 도면 값은 도면을 그리는 엔진이 채운다.
DRAWING_FIELDS: tuple[tuple[str, str, str], ...] = (
("공사명", "프로젝트", "프로젝트 이름"),
("위치", "프로젝트", "사업 위치"),
("시행청", "프로젝트", "발주 기관"),
("연도기번", "프로젝트", "프로젝트 번호"),
("사업량", "프로젝트", "사업량"),
("설계일자", "프로젝트", "설계 일자"),
("용역회사", "프로젝트", "회사 이름"),
("설계자", "프로젝트", "설계자 이름"),
("과업책임자", "프로젝트", "과업책임자 이름"),
("분야별책임자", "프로젝트", "분야별책임자 이름"),
("회사로고", "프로젝트", "회사 로고 그림"),
("설계자서명", "프로젝트", "설계자 서명 그림"),
("과업책임자서명", "프로젝트", "과업책임자 서명 그림"),
("분야별책임자서명", "프로젝트", "분야별책임자 서명 그림"),
("도면명", "도면", "도면 이름"),
("도면번호", "도면", "도면 목록 순번"),
("축척_A1", "도면", "A1 축척 분모"),
("축척_A3", "도면", "A3 축척 분모"),
)
@router.post("/drawing-import")
async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다."""
try:
data = await file.read()
if len(data) > _IMPORT_MAX_BYTES:
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
validate_template_entities(entities)
return JSONResponse(
{
"status": "success",
"drawing": frame_document(entities),
"entity_count": len(entities),
}
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("M02 도각 불러오기 실패: %s", file.filename)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
)
@router.get("/drawing-fields")
async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse:
"""자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용)."""
try:
values = await title_block_fields(project_id) if project_id else {}
except Exception:
logger.exception("M02 자리표 값 조회 실패: project_id=%s", project_id)
values = {}
return JSONResponse(
{
"status": "success",
"fields": [
{"key": key, "source": source, "label": label}
for key, source, label in DRAWING_FIELDS
],
"values": values,
}
)