From 4b5a630f12ba3c097debfda7f7771b666e77ed29 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 17:59:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(B02=C2=B7A00):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=AA=85=20=EC=97=B0=EB=8F=99=20=EB=81=8A?= =?UTF-8?q?=EA=B9=80=C2=B7=ED=95=AD=EB=AA=A9=20=EC=88=9C=EC=84=9C=C2=B7?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=85=9C=ED=94=8C=EB=A6=BF=C2=B7=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EB=B2=84=ED=8A=BC=20=EA=B0=84=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트명 조합기를 글자 단위 소유 추적으로 다시 만듦(`B02_ProjRegister_UI_Name.ts`) — 자동 글자 사이에 글을 끼워 넣어도 사업연도·사업지역·임도종류 연동이 끊기지 않음 (실측: 「울진군 금강송면」의 「금강송면」 앞 삽입 후에도 지역·종류·연도 변경이 반영) - 비고 칸을 맨 마지막으로 이동 - 제목·여백·버튼 줄을 공용 템플릿(`createGeneralLayout` · `ui-general-block__actions`)으로 전환, 화면 전용 제목 스타일 제거 - 헤더의 사용자명·로그아웃(로그인·회원가입) 버튼이 붙어 보이던 것 간격 부여 Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/app_shell.ts | 7 ++ B02_ProjRegister/B02_ProjRegister_UI_Name.ts | 96 +++++++++++++++++++ B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 71 +++++--------- .../B02_ProjRegister_UI_Style.css | 37 +------ 4 files changed, 130 insertions(+), 81 deletions(-) create mode 100644 B02_ProjRegister/B02_ProjRegister_UI_Name.ts diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 37ed31cb..0266dab6 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -251,6 +251,13 @@ const SHELL_CSS = ` align-items: center; gap: var(--spacing-8); } +/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다 + (2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */ +.app-actions__auth { + display: flex; + align-items: center; + gap: var(--spacing-8); +} .app-outlet { min-height: calc(100vh - 64px - 56px); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Name.ts b/B02_ProjRegister/B02_ProjRegister_UI_Name.ts new file mode 100644 index 00000000..cc4db59b --- /dev/null +++ b/B02_ProjRegister/B02_ProjRegister_UI_Name.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * B02_ProjRegister_UI_Name.ts + * 프로젝트명 조합기 — 사업연도·사업지역·임도종류를 이름 칸에 넣어 두고, 사용자가 그 글을 + * 고쳐도 위 항목과의 연결이 끊기지 않게 지킨다 (2026-09-06 사용자 지시). + * + * 글자마다 「누가 넣은 글자인가」를 같이 들고 다닌다. 문자열을 찾아 바꾸던 종전 방식은 + * 사용자가 자동 글자 사이에 한 글자만 끼워 넣어도 찾기가 실패해 연동이 통째로 끊겼다 + * (실측: 사업지역 「울진군 금강송면」의 「금강송면」 앞에 글자 삽입). + * + * 규칙 + * - 사용자가 이름을 고치기 전에는 위 세 항목으로 통째로 다시 쓴다. + * - 고친 뒤에는 **그 항목이 차지한 구간만** 새 값으로 갈아 끼운다. 사용자가 그 구간 + * 밖(사이·끝)에 넣은 글은 그대로 남는다. + * - 사용자가 지워 버린 항목은 되살리지 않는다 — 지운 것도 사용자의 뜻이다. + * ========================================================================== */ + +/** 이름을 이루는 자동 조각의 종류. 순서가 곧 이름에 놓이는 차례다. */ +export type NameSlot = "year" | "region" | "type"; + +const SLOTS: readonly NameSlot[] = ["year", "region", "type"]; + +export type NameParts = Record; + +export interface NameComposer { + /** 지금 이름. */ + text(): string; + /** 사용자가 이름 칸에 친 글을 반영한다. */ + edit(next: string): void; + /** 위 항목이 바뀌었을 때 — 갈아 끼운 이름을 돌려준다. */ + update(parts: NameParts): string; +} + +export function createNameComposer(): NameComposer { + let text = ""; + /** 글자마다 그 글자를 넣은 항목(사용자가 친 글자는 null). `text`와 길이가 같다. */ + let owners: (NameSlot | null)[] = []; + let touched = false; + + const fill = (slot: NameSlot | null, count: number): (NameSlot | null)[] => + Array.from({ length: count }, () => slot); + + const rebuild = (parts: NameParts): void => { + text = ""; + owners = []; + for (const slot of SLOTS) { + const value = parts[slot].trim(); + if (!value) continue; + if (text.length > 0) { + text += " "; + owners.push(null); + } + text += value; + owners.push(...fill(slot, value.length)); + } + }; + + return { + text: () => text, + + edit(next: string): void { + touched = true; + // 앞뒤로 그대로인 부분을 뺀 **바뀐 구간**만 계산한다 — 어디를 고쳤는지 알아야 + // 나머지 글자의 주인이 밀리지 않는다. + let head = 0; + while (head < text.length && head < next.length && text[head] === next[head]) head += 1; + let tail = 0; + while ( + tail < text.length - head && + tail < next.length - head && + text[text.length - 1 - tail] === next[next.length - 1 - tail] + ) { + tail += 1; + } + const removed = text.length - tail - head; + const inserted = next.slice(head, next.length - tail); + owners.splice(head, removed, ...fill(null, inserted.length)); + text = next; + }, + + update(parts: NameParts): string { + if (!touched) { + rebuild(parts); + return text; + } + for (const slot of SLOTS) { + const first = owners.indexOf(slot); + if (first < 0) continue; // 사용자가 지운 항목은 되살리지 않는다. + const last = owners.lastIndexOf(slot); + const value = parts[slot].trim(); + text = text.slice(0, first) + value + text.slice(last + 1); + owners.splice(first, last - first + 1, ...fill(slot, value.length)); + } + return text; + }, + }; +} diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 83e89830..68faf689 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -19,8 +19,10 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { isBlank } from "@util/common_util_validate"; import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -34,19 +36,6 @@ function L(key: keyof typeof ui_locales): string { * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderB02ProjRegister(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "b02-proj"; - - const header = document.createElement("div"); - header.className = "b02-proj__header"; - const title = document.createElement("h1"); - title.className = "b02-proj__title"; - title.textContent = L("B02_Proj_Title"); - const subtitle = document.createElement("p"); - subtitle.className = "b02-proj__subtitle"; - subtitle.textContent = L("B02_Proj_Subtitle"); - header.append(title, subtitle); - // 입력 필드 // 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이다 // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도 @@ -137,38 +126,18 @@ export function renderB02ProjRegister(root: HTMLElement): void { const roadTypeText = (): string => roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; - /** 이름을 이루는 자동 조각 — 사업연도·사업지역·임도종류 순서다. */ - const nameParts = (): string[] => [ - yearField.input.value.trim(), - regionField.input.value.trim(), - roadTypeText(), - ]; - let lastParts = nameParts(); - let nameTouched = false; + /** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */ + const nameComposer = createNameComposer(); + const nameParts = (): NameParts => ({ + year: yearField.input.value.trim(), + region: regionField.input.value.trim(), + type: roadTypeText(), + }); nameField.input.addEventListener("input", () => { - nameTouched = true; + nameComposer.edit(nameField.input.value); }); - /** - * 위 세 항목이 바뀌면 이름 칸을 따라 고친다. - * - * 아직 손대지 않은 칸은 통째로 다시 쓰고, 사용자가 고친 뒤에는 **바뀐 조각만 갈아 끼운다** - * — 사이나 끝에 넣은 글이 지워지지 않게 하기 위해서다. 지웠던 조각은 되살리지 않는다. - */ const syncName = (): void => { - const next = nameParts(); - if (!nameTouched) { - nameField.input.value = next.filter((part) => part.length > 0).join(" "); - } else { - let text = nameField.input.value; - next.forEach((part, index) => { - const before = lastParts[index]; - if (before && part && part !== before && text.includes(before)) { - text = text.replace(before, part); - } - }); - nameField.input.value = text; - } - lastParts = next; + nameField.input.value = nameComposer.update(nameParts()); }; const composedName = (): string => nameField.input.value.trim(); const routeLength = (): number | null => { @@ -202,7 +171,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { variant: "filled", onClick: onB02_Proj_Submit_Click, }); - submitBtn.classList.add("b02-proj__submit"); const cancelBtn = createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", @@ -316,8 +284,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { regionField.root, roadTypeField.root, nameField.root, - // 비고는 노선 구간 앞에 둔다 (2026-09-06 사용자 지시). - memoField.root, routeStartField.root, routeEndField.root, clientOrgField.root, @@ -325,12 +291,21 @@ export function renderB02ProjRegister(root: HTMLElement): void { pmField.root, fieldLeadField.root, designerField.root, + // 비고는 맨 끝 (2026-09-06 사용자 지시). + memoField.root, ); + // 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은 + // 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다. const actions = document.createElement("div"); - actions.className = "b02-proj__actions"; + actions.className = "ui-general-block__actions b02-proj__actions"; actions.append(cancelBtn, submitBtn); const card = createCard({ body: [grid, actions], raised: true }); - page.append(header, card); - root.append(page); + const layout = createGeneralLayout({ + pageClass: "b02-proj", + title: L("B02_Proj_Title"), + subtitle: L("B02_Proj_Subtitle"), + content: card, + }); + root.append(layout.root); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index a1b10774..f83aa46a 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -3,31 +3,8 @@ * 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용) * ========================================================================== */ -.b02-proj { - max-width: 720px; - margin: 0 auto; - padding: var(--spacing-40) var(--spacing-24); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} - -.b02-proj__header { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.b02-proj__title { - font-family: var(--font-display); - font-size: var(--text-heading); - color: var(--color-plum-velvet); -} - -.b02-proj__subtitle { - font-size: var(--text-body-sm); - color: var(--color-text-muted); -} +/* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는 + 이 화면에만 필요한 것(2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */ /* 2열 필드 그리드 (좁은 화면에서는 1열) */ .b02-proj__grid { @@ -53,20 +30,14 @@ padding-right: var(--spacing-32); } -.b02-proj__submit { - align-self: flex-start; - margin-top: var(--spacing-8); -} - @media (max-width: 640px) { .b02-proj__grid { grid-template-columns: 1fr; } } -/* 취소·등록 버튼 줄 */ +/* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */ .b02-proj__actions { - display: flex; - gap: var(--spacing-8); justify-content: flex-end; + margin-top: var(--spacing-16); }