refactor(M02): 좌측 패널을 시스템 양식 목록만으로 — 층 · 프로젝트 · 프로젝트 단추 다섯 뺌 · 제목 줄 headerSlot 연결

작도 영역 칸은 제목 줄 가운데로. 그림 칸 위쪽 246px.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
This commit is contained in:
2026-09-25 17:55:38 +09:00
co-authored by Claude Sonnet 5
parent 5b6cf0103e
commit 39c79f05d0
7 changed files with 42 additions and 304 deletions
@@ -46,7 +46,7 @@
.m02-drawing__area {
display: flex;
align-items: flex-end;
gap: var(--spacing-8);
gap: var(--spacing-4, 4px);
}
.m02-drawing__area {
@@ -63,7 +63,7 @@
}
.m02-drawing__area .ui-field input {
width: 4.5rem;
width: 3rem;
padding-block: 2px;
}
@@ -73,11 +73,14 @@
}
.m02-drawing__toolbar--slot {
flex: 0 1 auto;
flex: 1 1 0;
min-width: 0;
gap: var(--spacing-8);
}
.m02-drawing__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
white-space: nowrap;
}
@@ -73,7 +73,7 @@ export interface DrawingTemplateHandle {
/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
const AREA_LABELS = ["왼x", "아래y", "오른x", "위y"];
async function importDrawingFile(
file: File,
@@ -112,10 +112,6 @@ export function mountDrawingTemplate(
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다.
const area = document.createElement("div");
area.className = "m02-drawing__area";
const areaTitle = document.createElement("span");
areaTitle.className = "m02-drawing__title";
areaTitle.textContent = "작도 영역";
areaTitle.title = "도면 내용이 놓이는 칸 — 양식 좌표 mm";
const start = doc.drawing_area ?? DEFAULT_AREA;
const areaInputs = AREA_LABELS.map((label, index) => {
const field = createInputField({ label, type: "number", value: String(start[index]) });
@@ -123,7 +119,8 @@ export function mountDrawingTemplate(
field.input.addEventListener("input", () => options.onChanged?.(true));
return field;
});
area.append(areaTitle, ...areaInputs.map((field) => field.root));
area.title = "작도 영역 — 도면 내용이 놓이는 칸(양식 좌표 mm)";
area.append(...areaInputs.map((field) => field.root));
const readArea = (): [number, number, number, number] | null => {
const values = areaInputs.map((field) => Number(field.input.value));
@@ -189,10 +186,11 @@ export function mountDrawingTemplate(
.finally(() => (importButton.disabled = false));
});
const importButton = createButton({
label: "파일 불러오기",
label: "📂",
variant: "ghost",
onClick: () => fileInput.click(),
});
importButton.title = importButton.ariaLabel = "파일 불러오기 (DXF · DWG)";
actions.append(importButton, fileInput);
}
@@ -62,12 +62,13 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
const root = el("div", {
className: "m02-main",
children: [
el("div", { className: "m02-main__head", children: [title, reload, save] }),
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
notice,
host,
],
@@ -110,6 +111,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
const drop = (): void => {
editor?.destroy();
editor = null;
slot.replaceChildren();
};
async function mount(doc: unknown, at: Selection): Promise<void> {
@@ -141,6 +143,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
layer: at.layer,
projectId: at.projectId,
onChanged: (d: boolean) => (dirty = d && !readOnly),
headerSlot: slot,
});
return;
}
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_UI_Page.ts
* 마스터 템플릿 화면 — 좌측 도킹 패널(층 · 양식 목록) / 우측 편집 칸(표·도면 부품)
* 마스터 템플릿 화면 — 좌측 도킹 패널(시스템 양식 목록) / 우측 편집 칸(표·도면 부품)
*
* 화면은 로그인만 확인 — 시스템 층 고치기는 시스템 관리자일 때만 켬(서버도 막음).
* ========================================================================== */
@@ -20,8 +20,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
const main = createMain(isAdmin, () => void side.refresh());
const side = buildSide({
isAdmin,
isCompanyAdmin: isAdmin || user?.role === "ADMIN" || !!user?.is_master,
userName: user?.name,
onOpen: (sel) => void main.open(sel),
getOpen: main.current,
confirmLeave: main.confirmLeave,
@@ -1,7 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 층 고르기 · 프로젝트 고르기 · 양식 목록(표/도면) · 새로 · 본떠 · 지우기
* · 프로젝트 층 단추 다섯. 서버 길은 계약 `6_계약.md`.
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면) · 새로 · 본떠 · 지우기. 서버 길은 계약 `6_계약.md`.
* ========================================================================== */
import {
@@ -14,37 +13,21 @@ import {
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { fetchUserProjects, type ProjectItem } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import {
applyToProject,
deleteLayerTemplate,
deleteTemplate,
fetchSources,
listTemplates,
readTemplate,
resetProject,
saveProjectAs,
saveTemplate,
StaleError,
type Kind,
type Layer,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
const LAYERS: { layer: Layer; label: () => string }[] = [
{ layer: "system", label: () => L("M02_LayerSystem") },
{ layer: "company", label: () => L("M02_LayerCompany") },
{ layer: "personal", label: () => L("M02_LayerPersonal") },
{ layer: "project", label: () => L("M02_LayerProject") },
];
const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
const LAYER_KEY = "m02:layer";
const PROJECT_KEY = "m02:project";
/** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */
function stubDoc(kind: Kind, name: string): unknown {
return kind === "table"
@@ -61,29 +44,8 @@ function stubDoc(kind: Kind, name: string): unknown {
: { format: 6, entities: [] };
}
const store = {
get: (key: string): string => {
try {
return window.sessionStorage.getItem(key) ?? "";
} catch {
return "";
}
},
set: (key: string, value: string): void => {
try {
window.sessionStorage.setItem(key, value);
} catch {
// 저장소가 막혀도 화면은 동작
}
},
};
export interface SideOptions {
isAdmin: boolean;
/** 회사 관리자(회사 층 지우기 · 회사 공식으로 저장) */
isCompanyAdmin?: boolean;
/** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */
userName?: string;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
@@ -103,98 +65,11 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
type ApplyFrom = Parameters<typeof applyToProject>[1];
interface Source {
label: string;
body: ApplyFrom;
}
/** 가져올 곳 — 서버 `sources` 는 층마다 묶음 · 개인·프로젝트는 사람·프로젝트 아래 양식 목록 */
function sourceItems(raw: unknown): Source[] {
const group = (raw ?? {}) as Record<string, Record<string, unknown>[] | undefined>;
const layerName: Record<string, string> = {
system: L("M02_LayerSystem"),
company: L("M02_LayerCompany"),
personal: L("M02_LayerPersonal"),
project: L("M02_LayerProject"),
};
const out: Source[] = [];
for (const from of ["system", "company", "personal", "project"] as const) {
for (const entry of group[from] ?? []) {
const templates = (Array.isArray(entry.양식) ? entry.양식 : [entry]) as TemplateInfo[];
const owner = entry.name ? ` · ${String(entry.name)}` : "";
for (const t of templates) {
const body: ApplyFrom = { from, 종류: t.종류, 이름: t.이름 };
if (entry.user_id != null) body.user_id = String(entry.user_id);
if (entry.project_id != null) body.project_id = String(entry.project_id);
out.push({ label: `${layerName[from]}${owner} · ${t.이름}`, body });
}
}
}
return out;
}
export function buildSide(opt: SideOptions): SideHandle {
const savedLayer = store.get(LAYER_KEY) as Layer;
let layer: Layer = LAYERS.some((l) => l.layer === savedLayer) ? savedLayer : "project";
if (layer === "system" && !opt.isAdmin) layer = "project";
// 마지막에 고른 프로젝트는 localStorage — 다시 들어와도 유지
const lastProject = (): string => {
try {
return window.localStorage.getItem(PROJECT_KEY) ?? "";
} catch {
return "";
}
};
let projectId = lastProject();
let projects: ProjectItem[] = [];
const layer = "system" as const;
let items: TemplateInfo[] = [];
let seq = 0;
const layerButtons = LAYERS.map(({ layer: id, label }) => {
const button = createButton({ label: label(), variant: "ghost" });
button.disabled = id === "system" && !opt.isAdmin;
button.addEventListener("click", async () => {
if (id !== layer && !(await opt.confirmLeave())) return;
layer = id;
store.set(LAYER_KEY, id);
opt.onOpen(null);
void refresh();
});
return { id, button };
});
const layerRow = el("div", {
className: "m02-side__layers",
children: layerButtons.map((b) => b.button),
});
const remember = (id: string): void => {
try {
window.localStorage.setItem(PROJECT_KEY, id);
} catch {
// 저장소가 막혀도 화면은 동작
}
};
// 없으면 내가 만든 프로젝트 중 최근 것 · 그래도 없으면 빈 칸
const defaultProject = (): string => {
const mine = projects
.filter((p) => opt.userName && p.owner_name === opt.userName)
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
return mine[0] ? String(mine[0].id) : "";
};
const projectField = createSelectField({ label: L("M02_Project"), options: [] });
projectField.select.addEventListener("change", async () => {
if (!(await opt.confirmLeave())) {
projectField.select.value = projectId;
return;
}
projectId = projectField.select.value;
remember(projectId);
opt.onOpen(null);
void refresh();
});
const listHost = el("div", { className: "m02-side__lists" });
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
@@ -202,14 +77,7 @@ export function buildSide(opt: SideOptions): SideHandle {
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger" });
const editRow = el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] });
const projectRow = el("div", { className: "m02-side__row" });
const root = el("div", {
className: "m02-side",
children: [layerRow, projectField.root, listHost, editRow, projectRow],
});
const isProject = (): boolean => layer === "project";
const pid = (): string | null => (isProject() && projectId ? projectId : null);
const root = el("div", { className: "m02-side", children: [listHost, editRow] });
const isOpen = (info: TemplateInfo): boolean => {
const cur = opt.getOpen();
@@ -247,23 +115,15 @@ export function buildSide(opt: SideOptions): SideHandle {
}
function paintButtons(): void {
layerButtons.forEach(({ id, button }) => button.classList.toggle("is-active", id === layer));
projectField.root.hidden = !isProject();
const editable = layer === "system" && canEdit(layer, opt.isAdmin);
const canDelete =
editable || layer === "personal" || (layer === "company" && !!opt.isCompanyAdmin);
editRow.hidden = isProject();
btnNew.hidden = btnCopy.hidden = layer !== "system";
const editable = canEdit(layer, opt.isAdmin);
btnNew.disabled = !editable;
btnCopy.disabled = !editable || !currentInfo();
btnDel.disabled = !canDelete || !currentInfo();
projectRow.hidden = !isProject();
projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId));
btnDel.disabled = !editable || !currentInfo();
}
async function select(info: TemplateInfo): Promise<void> {
if (!isOpen(info) && !(await opt.confirmLeave())) return;
opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 });
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
paintList();
paintButtons();
}
@@ -271,24 +131,8 @@ export function buildSide(opt: SideOptions): SideHandle {
async function refresh(): Promise<void> {
const mine = ++seq;
paintButtons();
if (isProject()) {
if (!projects.length) {
try {
projects = await fetchUserProjects();
} catch (error) {
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
}
if (!projects.some((p) => String(p.id) === projectId)) projectId = defaultProject();
projectField.setOptions([
{ value: "", text: L(projects.length ? "M02_PickProject" : "M02_NoProjects") },
...projects.map((p) => ({ value: String(p.id), text: p.name ?? String(p.id) })),
]);
projectField.select.value = projectId;
remember(projectId);
}
try {
items = isProject() && !projectId ? [] : await listTemplates(layer, pid());
items = await listTemplates(layer, null);
} catch (error) {
items = [];
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
@@ -364,8 +208,7 @@ export function buildSide(opt: SideOptions): SideHandle {
if (!info) return;
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
try {
if (layer === "system") await deleteTemplate(info.종류, info.이름, info.판);
else await deleteLayerTemplate(layer, info.종류, info.이름, info.판);
await deleteTemplate(info.종류, info.이름, info.판);
showToast(L("M02_Deleted"), "success");
opt.onOpen(null);
await refresh();
@@ -374,86 +217,6 @@ export function buildSide(opt: SideOptions): SideHandle {
}
});
/* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */
const act = async (run: () => Promise<unknown>): Promise<void> => {
if (!(await opt.confirmLeave())) return;
try {
await run();
showToast(L("M02_Done"), "success");
opt.onOpen(null);
await refresh();
} catch (error) {
failed(error);
}
};
const projectButton = (label: string, onClick: () => void): void => {
const b = createButton({ label, variant: "ghost" });
b.addEventListener("click", onClick);
projectRow.append(b);
};
const saveOpenAs = async (to: "personal" | "company"): Promise<void> => {
const cur = opt.getOpen();
if (!cur) return void showToast(L("M02_PickTemplate"), "error");
try {
const rows = await listTemplates(to, null);
const same = rows.some((r) => r.종류 === cur.kind && r.이름 === cur.name);
if (
same &&
!(await showConfirmDialog(L("M02_OverwriteConfirm").replace("{value}", cur.name)))
) {
return;
}
} catch (error) {
return failed(error);
}
try {
await saveProjectAs(projectId, to, cur.kind, cur.name);
showToast(L("M02_Done"), "success"); // 열던 양식은 그대로 둠
} catch (error) {
failed(error);
}
};
projectButton(L("M02_ProjectReset"), async () => {
if (await showConfirmDialog(L("M02_ResetConfirm"))) void act(() => resetProject(projectId));
});
projectButton(L("M02_ProjectApplyCompany"), async () => {
if (await showConfirmDialog(L("M02_ApplyConfirm"))) {
void act(() => applyToProject(projectId, { from: "company" }));
}
});
projectButton(L("M02_ProjectImport"), async () => {
let sources: Source[] = [];
try {
sources = sourceItems(await fetchSources(projectId));
} catch (error) {
return failed(error);
}
openModal({
title: L("M02_ImportTitle"),
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
if (!sources.length) {
body.append(el("p", { className: "m02-side__empty", text: L("M02_ImportNone") }));
}
for (const src of sources) {
const b = el("button", {
className: "m02-side__item",
text: src.label,
attrs: { type: "button" },
});
b.addEventListener("click", () => {
close();
void act(() => applyToProject(projectId, src.body));
});
body.append(b);
}
},
});
});
projectButton(L("M02_ProjectSavePersonal"), () => void saveOpenAs("personal"));
projectButton(L("M02_ProjectSaveCompany"), () => void saveOpenAs("company"));
void refresh();
return { root, refresh };
}
@@ -6,24 +6,6 @@
padding-top: var(--spacing-8);
}
.m02-side__layers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
gap: var(--spacing-4);
}
.m02-side__layers .ui-btn {
padding-inline: var(--spacing-4);
white-space: nowrap;
}
.m02-side__layers .ui-btn.is-active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent, #fff);
font-weight: var(--font-weight-bold);
}
.m02-side__group {
display: flex;
flex-direction: column;
@@ -79,18 +61,34 @@
.m02-main__head {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-12);
}
.m02-main__title {
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 3rem;
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m02-main__head .ui-btn {
white-space: nowrap;
}
.m02-main__slot {
display: flex;
flex: 1 1 auto;
align-items: center;
min-width: 0;
}
.m02-main__notice {
display: flex;
align-items: center;
-25
View File
@@ -6,14 +6,6 @@
export const ui_locales_m2 = {
B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"],
M02_Title: ["마스터 템플릿", "Master Templates"],
M02_Layer: ["층", "Layer"],
M02_LayerSystem: ["시스템", "System"],
M02_LayerCompany: ["회사", "Company"],
M02_LayerPersonal: ["개인", "Personal"],
M02_LayerProject: ["프로젝트", "Project"],
M02_Project: ["프로젝트", "Project"],
M02_PickProject: ["프로젝트를 고를 것", "Pick a project"],
M02_NoProjects: ["프로젝트 없음", "No projects"],
M02_KindTable: ["표 양식", "Table templates"],
M02_KindDrawing: ["도면 양식", "Drawing templates"],
M02_NoTemplates: ["양식 없음", "None"],
@@ -44,21 +36,6 @@ export const ui_locales_m2 = {
],
M02_NoSheet: ["표 편집 부품이 아직 없음", "Table editor not available yet"],
M02_NoDrawing: ["도면 편집 부품이 아직 없음", "Drawing editor not available yet"],
M02_ProjectReset: ["초기화", "Reset"],
M02_ProjectApplyCompany: ["회사 양식 적용", "Apply company templates"],
M02_ProjectImport: ["양식 가져오기", "Import template"],
M02_ProjectSavePersonal: ["내 양식으로 저장", "Save as mine"],
M02_ProjectSaveCompany: ["회사 공식으로 저장", "Save as company official"],
M02_ResetConfirm: [
"프로젝트 양식을 처음 복사한 것으로 되돌릴까?",
"Reset project templates to the initial copy?",
],
M02_ApplyConfirm: [
"회사 양식을 프로젝트 양식에 덮어쓸까?",
"Overwrite project templates with the company's?",
],
M02_ImportTitle: ["양식 가져오기", "Import template"],
M02_ImportNone: ["가져올 양식 없음", "Nothing to import"],
M02_NameExists: ["이미 있는 이름", "That name already exists"],
M02_BadName: [
'쓸 수 없는 글자 — / \\ : * ? " < > | 와 .. · 앞의 점은 안 됨',
@@ -68,7 +45,5 @@ export const ui_locales_m2 = {
"저장 안 한 고침이 있음 — 버리고 넘어갈까?",
"Unsaved changes — discard and continue?",
],
M02_OverwriteConfirm: ["「{value}」 이(가) 이미 있음 — 덮을까?", "“{value}” exists — overwrite?"],
M02_Done: ["했음", "Done"],
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
} as const;