From 483c6fd7c8062a1c7351e715e00da144156f09a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 25 Sep 2026 09:36:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EB=A7=88=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=8B=80=20=E2=80=94=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=C2=B7=20=EA=B8=B8=20=C2=B7=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90(=EC=B8=B5=C2=B7=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=83=88=EB=A1=9C=C2=B7=EB=B3=B8=EB=96=A0=C2=B7?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0=C2=B7=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=8B=A8=EC=B6=94=20=EB=8B=A4=EC=84=AF)=20=C2=B7?= =?UTF-8?q?=20=EB=A9=94=EC=9D=B8=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EB=AA=A8=EB=8B=AC=20(PLAN=2010-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95 --- A00_Common/main.ts | 1 + A00_Common/router.ts | 2 + B01_Dashboard/B01_Dashboard_UI_Page.ts | 5 + .../M01_MasterData_UI_LogicLab_Modal.ts | 29 +- .../M02_MasterTemplete_Api_Fetch.ts | 105 +++++ .../M02_MasterTemplete_UI_Main.ts | 172 ++++++++ .../M02_MasterTemplete_UI_Page.ts | 43 ++ .../M02_MasterTemplete_UI_Side.ts | 372 ++++++++++++++++++ .../M02_MasterTemplete_UI_Style.css | 114 ++++++ config/config_frontend.ts | 3 + ui_template/ui_template_locale.ts | 2 + ui_template/ui_template_locale_m2.ts | 64 +++ ui_template/ui_template_modal.css | 35 ++ ui_template/ui_template_modal.ts | 46 +++ 14 files changed, 971 insertions(+), 22 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Style.css create mode 100644 ui_template/ui_template_locale_m2.ts create mode 100644 ui_template/ui_template_modal.css create mode 100644 ui_template/ui_template_modal.ts diff --git a/A00_Common/main.ts b/A00_Common/main.ts index b8976151..529fdbc5 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -27,6 +27,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [ ROUTES.B10_PAYMENT, ROUTES.B11_STATUS, ROUTES.M01_MASTER_DATA, + ROUTES.M02_MASTER_TEMPLATE, ]; function bootstrap(): void { diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 3dea0a48..38f59c3d 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -61,6 +61,8 @@ const routeTable: Partial Promise>> = { (await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading, [ROUTES.M01_MASTER_DATA]: async () => (await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData, + [ROUTES.M02_MASTER_TEMPLATE]: async () => + (await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate, }; /** 로그인 여부 (토큰 존재 확인) */ diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 1ddbe343..18ac31c6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -139,6 +139,11 @@ function buildSystemSettingsPanel(): HTMLElement { variant: "ghost", onClick: () => navigateTo(ROUTES.M01_MASTER_DATA), }), + createButton({ + label: L("B01_Dashboard_MasterTemplate"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE), + }), ); return wrap; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 1d197668..3846bca1 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; import { searchElements, type ElementBrief, @@ -207,29 +208,13 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { /** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */ function openDialog(title: string, mount: (body: HTMLElement) => void): void { - const body = el("div", { className: "m01lab__modal-body" }); - const close = (): void => backdrop.remove(); - const dialog = el("div", { - className: "m01-logic__pick m01lab__modal", - attrs: { role: "dialog", "aria-label": title }, - children: [ - el("div", { - className: "m01lab__modal-top", - children: [ - el("h3", { text: title }), - createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }), - ], - }), - body, - ], + openModal({ + title, + closeLabel: tl("Modal_Close"), + mount, + dialogClass: "m01-logic__pick m01lab__modal", + backdropClass: "m01-logic__backdrop", }); - const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); - backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); - backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); - mount(body); - document.body.append(backdrop); - dialog.tabIndex = -1; - dialog.focus(); } const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__muted", text }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts new file mode 100644 index 00000000..00c8cca4 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * M02_MasterTemplete_Api_Fetch.ts + * 마스터 템플릿 서버 호출 — 계약 `6_계약.md` 서버 길 · 시스템 층은 시스템 관리자만 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 층 이름 — 서버와 같은 글 */ +export type Layer = "system" | "company" | "personal" | "project"; +/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing */ +export type Kind = "table" | "drawing"; + +export interface TemplateInfo { + 종류: Kind; + 이름: string; + 판: string; + 수정일: string; +} + +export interface TemplateDoc { + 종류: Kind; + 이름: string; + 판: string; + 문서: unknown; +} + +/** 서버가 판 불일치로 막았을 때 */ +export class StaleError extends Error {} + +async function call(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}/m02${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (response.status === 409) throw new StaleError(String(body.detail ?? "409")); + if (!response.ok) { + const detail = body.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body; +} + +const enc = encodeURIComponent; +const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); +const head = (layer: Layer): string => + layer === "system" ? "/templates" : `/layers/${layer}/templates`; +const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => + `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`; + +export const listTemplates = (layer: Layer, projectId: string | null): Promise => + call(layer === "system" ? head(layer) : `${head(layer)}${q(projectId)}`); + +export const readTemplate = ( + layer: Layer, + kind: Kind, + name: string, + projectId: string | null, +): Promise => call(item(layer, kind, name, projectId)); + +export const saveTemplate = ( + layer: Layer, + kind: Kind, + name: string, + projectId: string | null, + 판: string, + 문서: unknown, +): Promise => + call(item(layer, kind, name, projectId), { method: "PUT", body: JSON.stringify({ 판, 문서 }) }); + +/** 시스템 층만 지움 */ +export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => + call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + +/* --- 프로젝트 층 단추 다섯 --- */ +const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`; +const post = (path: string, body: object = {}): Promise => + call(path, { method: "POST", body: JSON.stringify(body) }); + +export const resetProject = (id: string): Promise => post(project(id, "reset")); + +export const applyToProject = ( + id: string, + from: { from: Layer; user_id?: string; project_id?: string; 종류?: Kind; 이름?: string }, +): Promise => post(project(id, "apply"), from); + +export const saveProjectAs = ( + id: string, + to: "personal" | "company", + 종류: Kind, + 이름: string, +): Promise => post(project(id, "save-as"), { to, 종류, 이름 }); + +export interface SourceItem { + from: Layer; + label?: string; + user_id?: string; + project_id?: string; + 종류?: Kind; + 이름?: string; + [key: string]: unknown; +} + +export const fetchSources = (id: string): Promise => call(`/projects/${enc(id)}/sources`); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts new file mode 100644 index 00000000..cda7a423 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -0,0 +1,172 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Main.ts + * 마스터 템플릿 메인 칸 — 양식 한 개를 읽어 표·도면 편집 부품에 붙임(계약 `6_계약.md` 화면 부품). + * 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + readTemplate, + saveTemplate, + StaleError, + type Kind, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; + +export interface Selection { + layer: Layer; + projectId: string | null; + kind: Kind; + name: string; +} + +interface EditorHandle { + getDoc: () => unknown; + destroy: () => void; +} + +type Mods = Record Promise>; +// 표 부품(sub3) · 도면 부품(sub2) — 파일 이름은 각 창이 정함 → 폴더를 훑어 내보낸 이름으로 찾음 +const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/*.ts"); +const DRAWING_MODS: Mods = import.meta.glob("./M02_MasterTemplete_Drawing*.ts"); + +async function findExport(mods: Mods, name: string): Promise { + for (const load of Object.values(mods)) { + try { + const mod = (await load()) as Record; + if (typeof mod[name] === "function") return mod[name] as T; + } catch { + // 못 읽는 파일은 건너뜀 — 페이지는 뜸 + } + } + return null; +} + +export interface MainHandle { + root: HTMLElement; + open: (sel: Selection | null) => Promise; + current: () => Selection | null; +} + +/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */ +export function canEdit(layer: Layer, isAdmin: boolean): boolean { + return layer === "project" || layer === "personal" || (layer === "system" && isAdmin); +} + +export function createMain(isAdmin: boolean): 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 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] }), + notice, + host, + ], + }); + + let sel: Selection | null = null; + let version = ""; + let loaded: unknown = null; + let editor: EditorHandle | null = null; + let seq = 0; + + const showNotice = (nodes: (HTMLElement | string)[]): void => { + notice.replaceChildren(...nodes); + notice.hidden = nodes.length === 0; + }; + const empty = (text: string): void => { + host.replaceChildren(el("p", { className: "m02-main__empty", text })); + }; + const bar = (): void => { + reload.hidden = !sel; + save.hidden = !sel || !canEdit(sel.layer, isAdmin); + }; + + const drop = (): void => { + editor?.destroy(); + editor = null; + }; + + async function mount(doc: unknown, at: Selection): Promise { + const readOnly = !canEdit(at.layer, isAdmin); + host.replaceChildren(); + if (at.kind === "table") { + const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( + SHEET_MODS, + "createSheet", + ); + if (create) { + editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master" }); + return; + } + return empty(L("M02_NoSheet")); + } + const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( + DRAWING_MODS, + "mountDrawingTemplate", + ); + if (mountDrawing) { + editor = mountDrawing(host, doc, { onSave: () => void doSave(), readOnly }); + return; + } + empty(L("M02_NoDrawing")); + } + + async function open(next: Selection | null): Promise { + const mine = ++seq; + drop(); + showNotice([]); + sel = next; + bar(); + if (!next) { + title.textContent = L("M02_PickTemplate"); + return empty(""); + } + title.textContent = next.name; + try { + const got = await readTemplate(next.layer, next.kind, next.name, next.projectId); + if (mine !== seq) return; + version = got.판; + loaded = got.문서; + await mount(got.문서, next); + } catch (error) { + if (mine !== seq) return; + empty(""); + const why = error instanceof Error ? error.message : ""; + showToast(L("M02_LoadFailed").replace("{value}", why), "error"); + } + } + + async function doSave(): Promise { + if (!sel) return; + const at = sel; + try { + const doc = editor ? editor.getDoc() : loaded; + const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc); + version = info.판; + loaded = doc; + showNotice([]); + showToast(L("M02_Saved"), "success"); + } catch (error) { + if (error instanceof StaleError) { + const again = createButton({ label: L("M02_Reload"), variant: "ghost" }); + again.addEventListener("click", () => void open(at)); + showNotice([L("M02_Stale"), again]); + return; + } + const why = error instanceof Error ? error.message : ""; + showToast(L("M02_SaveFailed").replace("{value}", why), "error"); + } + } + + save.addEventListener("click", () => void doSave()); + reload.addEventListener("click", () => sel && void open(sel)); + bar(); + empty(""); + return { root, open, current: () => sel }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts new file mode 100644 index 00000000..809105b3 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Page.ts + * 마스터 템플릿 화면 — 좌측 도킹 패널(층 · 양식 목록) / 우측 편집 칸(표·도면 부품) + * + * 화면은 로그인만 확인 — 시스템 층 고치기는 시스템 관리자일 때만 켬(서버도 막음). + * ========================================================================== */ + +import "@ui/ui_template_workflow_layout.css"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { createMain } from "./M02_MasterTemplete_UI_Main"; +import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import "./M02_MasterTemplete_UI_Style.css"; + +export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const user = await fetchSessionUser().catch(() => null); + const isAdmin = user?.role === "SYSTEM_ADMIN"; + const main = createMain(isAdmin); + const side = buildSide({ + isAdmin, + onOpen: (sel) => void main.open(sel), + getOpen: main.current, + }); + + const layout = el("div", { className: "ui-workflow-layout m02-master" }); + const overlays = createWorkflowOverlays({ + title: L("M02_Title"), + optionsContent: el("div", { className: "m02-master__side", children: [side.root] }), + showProjectName: false, + onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), + }); + layout.append( + el("div", { + className: "ui-workflow-layout__body", + children: [el("main", { className: "ui-workflow-layout__main", children: [main.root] })], + }), + overlays.root, + ); + root.innerHTML = ""; + root.append(layout); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts new file mode 100644 index 00000000..4834a141 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -0,0 +1,372 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side.ts + * 마스터 템플릿 좌측 패널 — 층 고르기 · 프로젝트 고르기 · 양식 목록(표/도면) · 새로 · 본떠 · 지우기 + * · 프로젝트 층 단추 다섯. 서버 길은 계약 `6_계약.md`. + * ========================================================================== */ + +import { + createButton, + createInputField, + createSelectField, + el, + showConfirmDialog, + showToast, +} 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, + deleteTemplate, + fetchSources, + listTemplates, + readTemplate, + resetProject, + saveProjectAs, + saveTemplate, + 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" + ? { + 양식: name, + 종류: "표", + 판: 1, + 층: ["종류", "공법", "규격"], + 열: [{ id: "sta", 머리: ["측점", null, null], 단위: null, 꼴: "글", 고정: true }], + 줄: [], + 합계줄: [], + 보기: {}, + } + : { 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; + onOpen: (sel: Selection | null) => void; + getOpen: () => Selection | null; +} + +export interface SideHandle { + root: HTMLElement; + refresh: () => Promise; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const failed = (error: unknown): void => + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + +/** 가져올 곳 목록 — 모양이 배열이든 묶음 객체든 한 줄씩 펴서 받음(길이 채워지면 맞춤) */ +function flatten(raw: unknown): Record[] { + if (Array.isArray(raw)) { + return raw.filter((x): x is Record => !!x && typeof x === "object"); + } + if (raw && typeof raw === "object") { + return Object.entries(raw).flatMap(([from, v]) => + flatten(v).map((item) => ({ from: item.from ?? from, ...item })), + ); + } + return []; +} + +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"; + let projectId = store.get(PROJECT_KEY); + let projects: ProjectItem[] = []; + 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", () => { + 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 projectField = createSelectField({ label: L("M02_Project"), options: [] }); + projectField.select.addEventListener("change", () => { + projectId = projectField.select.value; + store.set(PROJECT_KEY, projectId); + opt.onOpen(null); + void refresh(); + }); + + const listHost = el("div", { className: "m02-side__lists" }); + + const btnNew = createButton({ label: L("M02_New"), variant: "filled" }); + const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" }); + 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 isOpen = (info: TemplateInfo): boolean => { + const cur = opt.getOpen(); + return !!cur && cur.layer === layer && cur.kind === info.종류 && cur.name === info.이름; + }; + + const currentInfo = (): TemplateInfo | undefined => { + const cur = opt.getOpen(); + return cur ? items.find((i) => i.종류 === cur.kind && i.이름 === cur.name) : undefined; + }; + + function paintList(): void { + listHost.replaceChildren( + ...KINDS.map(({ kind, label }) => { + const mine = items.filter((i) => i.종류 === kind); + return el("div", { + className: "m02-side__group", + children: [ + el("h4", { text: label() }), + ...(mine.length + ? mine.map((info) => { + const b = el("button", { + className: `m02-side__item${isOpen(info) ? " is-active" : ""}`, + text: info.이름, + attrs: { type: "button", title: info.수정일 }, + }); + b.addEventListener("click", () => select(info)); + return b; + }) + : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), + ], + }); + }), + ); + } + + 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); + editRow.hidden = layer !== "system"; + btnNew.disabled = !editable; + btnCopy.disabled = btnDel.disabled = !editable || !currentInfo(); + projectRow.hidden = !isProject(); + projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId)); + } + + function select(info: TemplateInfo): void { + opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 }); + paintList(); + paintButtons(); + } + + async function refresh(): Promise { + 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 = projects[0] ? String(projects[0].id) : ""; + } + projectField.setOptions( + projects.length + ? projects.map((p) => ({ value: String(p.id), text: p.name ?? String(p.id) })) + : [{ value: "", text: L("M02_NoProjects") }], + ); + projectField.select.value = projectId; + store.set(PROJECT_KEY, projectId); + } + try { + items = isProject() && !projectId ? [] : await listTemplates(layer, pid()); + } catch (error) { + items = []; + showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); + } + if (mine !== seq) return; + paintList(); + paintButtons(); + } + + /* --- 시스템 층 새로 · 본떠 · 지우기 --- */ + const nameDialog = ( + title: string, + withKind: boolean, + onOk: (kind: Kind, name: string) => Promise, + ): void => { + const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); + const kind = createSelectField({ + label: L("M02_Kind"), + options: KINDS.map((k) => ({ value: k.kind, text: k.label() })), + }); + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal", + mount: (body, close) => { + const ok = createButton({ label: L("M02_Create"), variant: "filled" }); + ok.addEventListener("click", async () => { + const text = name.input.value.trim(); + if (!text) return showToast(L("M02_NameNeeded"), "error"); + try { + await onOk(kind.select.value as Kind, text); + close(); + } catch (error) { + failed(error); + } + }); + body.append(...(withKind ? [kind.root] : []), name.root, ok); + name.input.focus(); + }, + }); + }; + + const openCreated = async (kind: Kind, name: string): Promise => { + showToast(L("M02_Created"), "success"); + await refresh(); + const info = items.find((i) => i.종류 === kind && i.이름 === name); + if (info) select(info); + }; + + btnNew.addEventListener("click", () => + nameDialog(L("M02_NewTitle"), true, async (kind, name) => { + await saveTemplate("system", kind, name, null, "", stubDoc(kind, name)); + await openCreated(kind, name); + }), + ); + btnCopy.addEventListener("click", () => { + const from = currentInfo(); + if (!from) return; + nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => { + const got = await readTemplate("system", from.종류, from.이름, null); + await saveTemplate("system", from.종류, name, null, "", got.문서); + await openCreated(from.종류, name); + }); + }); + btnDel.addEventListener("click", async () => { + const info = currentInfo(); + if (!info) return; + if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return; + try { + await deleteTemplate(info.종류, info.이름, info.판); + showToast(L("M02_Deleted"), "success"); + opt.onOpen(null); + await refresh(); + } catch (error) { + failed(error); + } + }); + + /* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */ + const act = async (run: () => Promise): Promise => { + 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 = (to: "personal" | "company"): void => { + const cur = opt.getOpen(); + if (!cur) return void showToast(L("M02_PickTemplate"), "error"); + void act(() => saveProjectAs(projectId, to, cur.kind, cur.name)); + }; + 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: Record[] = []; + try { + sources = flatten(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 text = String(src.label ?? src.이름 ?? src.from ?? JSON.stringify(src)); + const b = el("button", { className: "m02-side__item", text, attrs: { type: "button" } }); + b.addEventListener("click", () => { + close(); + const { label: _label, ...rest } = src; + void act(() => applyToProject(projectId, rest as never)); + }); + body.append(b); + } + }, + }); + }); + projectButton(L("M02_ProjectSavePersonal"), () => saveOpenAs("personal")); + projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company")); + + void refresh(); + return { root, refresh }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css new file mode 100644 index 00000000..0ce4d0f6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -0,0 +1,114 @@ +/* M02 마스터 템플릿 — 좌측 패널 + 메인. 색·간격은 공용 토큰. */ +.m02-side { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + padding-top: var(--spacing-8); +} + +.m02-side__layers { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-4); +} + +.m02-side__layers .ui-btn.is-active { + border-color: var(--color-accent); + color: var(--color-accent); + font-weight: var(--font-weight-bold); +} + +.m02-side__group { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-side__group h4 { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-side__item { + width: 100%; + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-md); + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-side__item:hover { + background: var(--color-paper); +} + +.m02-side__item.is-active { + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + font-weight: var(--font-weight-bold); +} + +.m02-side__empty { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-side__row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.m02-main { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; + padding: var(--spacing-16) var(--spacing-24); +} + +.m02-main__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-12); +} + +.m02-main__title { + flex: 1 1 auto; + margin: 0; + color: var(--color-text); + font-size: var(--text-body); +} + +.m02-main__notice { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 10%, var(--color-canvas)); + color: var(--color-danger); + font-size: var(--text-body-sm); +} + +.m02-main__host { + min-height: 240px; +} + +.m02-main__empty { + margin: 0; + padding: var(--spacing-24); + color: var(--color-text-muted); + text-align: center; +} + +.m02-modal__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index d5abc5b0..fbd0538d 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -118,6 +118,8 @@ export const ROUTES = { B11_LOADING: "b11-loading", // 시스템 관리자 전용 — 마스터 요소(파일 첫 층 JSON) 보기·고치기. M01_MASTER_DATA: "m01-master-data", + // 마스터 템플릿 — 표 양식 · 도면 양식(시스템 · 회사 · 개인 · 프로젝트 층). 화면은 로그인만 확인. + M02_MASTER_TEMPLATE: "m02-master-template", } as const; export type RouteKey = keyof typeof ROUTES; @@ -141,6 +143,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [ ROUTES.B11_STATUS, ROUTES.B11_LOADING, ROUTES.M01_MASTER_DATA, + ROUTES.M02_MASTER_TEMPLATE, ]; /* ----------------------------------------------------------------------------- diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index a36c79d4..a285a8a6 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -25,6 +25,7 @@ import { ui_locales_b1 } from "./ui_template_locale_b1"; import { ui_locales_b2 } from "./ui_template_locale_b2"; import { ui_locales_b3 } from "./ui_template_locale_b3"; import { ui_locales_m1 } from "./ui_template_locale_m1"; +import { ui_locales_m2 } from "./ui_template_locale_m2"; /** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */ export const LANGUAGES = ["ko", "en"] as const; @@ -60,6 +61,7 @@ export const ui_locales = { ...ui_locales_b2, ...ui_locales_b3, ...ui_locales_m1, + ...ui_locales_m2, } as const; export type LocaleKey = keyof typeof ui_locales; diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts new file mode 100644 index 00000000..cb4a8225 --- /dev/null +++ b/ui_template/ui_template_locale_m2.ts @@ -0,0 +1,64 @@ +/* ============================================================================= + * ui_template_locale_m2.ts + * M02 마스터 템플릿 화면 사전 — 신규 문구는 최하단에 추가. + * ========================================================================== */ + +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"], + M02_New: ["새로", "New"], + M02_Copy: ["본떠 만들기", "Copy as new"], + M02_Delete: ["지우기", "Delete"], + M02_NewTitle: ["새 양식", "New template"], + M02_CopyTitle: ["본떠 만들기", "Copy as new"], + M02_Kind: ["종류", "Kind"], + M02_Name: ["이름", "Name"], + M02_Create: ["만들기", "Create"], + M02_Close: ["닫기", "Close"], + M02_NameNeeded: ["이름을 적을 것", "Enter a name"], + M02_DeleteConfirm: ["「{value}」 양식을 지울까?", "Delete template “{value}”?"], + M02_Deleted: ["지웠음", "Deleted"], + M02_Created: ["만들었음", "Created"], + M02_Saved: ["저장했음", "Saved"], + M02_Save: ["저장", "Save"], + M02_Reload: ["다시 열기", "Reopen"], + M02_PickTemplate: ["왼쪽에서 양식을 고를 것", "Pick a template on the left"], + + M02_ReadOnlyLayer: ["이 층은 여기서 고치지 못함", "This layer is read-only here"], + M02_LoadFailed: ["양식을 못 읽음 — {value}", "Failed to load — {value}"], + M02_SaveFailed: ["저장 못 함 — {value}", "Save failed — {value}"], + M02_Stale: [ + "그 사이 양식이 바뀜 — 다시 열어 새 판으로 고칠 것", + "The template changed meanwhile — reopen to edit the latest", + ], + 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_Done: ["했음", "Done"], + M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"], +} as const; diff --git a/ui_template/ui_template_modal.css b/ui_template/ui_template_modal.css new file mode 100644 index 00000000..9251175d --- /dev/null +++ b/ui_template/ui_template_modal.css @@ -0,0 +1,35 @@ +.ui-modal__backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: transparent; +} + +.ui-modal { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + width: min(720px, 92vw); + max-height: 80vh; + padding: var(--spacing-16); + border-radius: var(--radius-xl); + background: var(--color-surface-raised); + font-size: var(--text-body-sm); + overflow: auto; +} + +.ui-modal__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.ui-modal__body { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts new file mode 100644 index 00000000..ee92cc82 --- /dev/null +++ b/ui_template/ui_template_modal.ts @@ -0,0 +1,46 @@ +/* ============================================================================= + * ui_template_modal.ts + * 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움. + * M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임. + * ========================================================================== */ + +import "./ui_template_modal.css"; +import { createButton, el } from "./ui_template_elements"; + +export interface ModalOptions { + title: string; + closeLabel: string; + mount: (body: HTMLElement, close: () => void) => void; + dialogClass?: string; + backdropClass?: string; +} + +export function openModal(opts: ModalOptions): { close: () => void } { + const body = el("div", { className: "ui-modal__body" }); + const close = (): void => backdrop.remove(); + const dialog = el("div", { + className: `ui-modal ${opts.dialogClass ?? ""}`.trim(), + attrs: { role: "dialog", "aria-label": opts.title }, + children: [ + el("div", { + className: "ui-modal__top", + children: [ + el("h3", { text: opts.title }), + createButton({ label: opts.closeLabel, variant: "ghost", onClick: close }), + ], + }), + body, + ], + }); + const backdrop = el("div", { + className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(), + children: [dialog], + }); + backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); + backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); + opts.mount(body, close); + document.body.append(backdrop); + dialog.tabIndex = -1; + dialog.focus(); + return { close }; +}