fix(B02·A00): 프로젝트명 연동 끊김·항목 순서·공용 템플릿·헤더 버튼 간격

- 프로젝트명 조합기를 글자 단위 소유 추적으로 다시 만듦(`B02_ProjRegister_UI_Name.ts`) —
  자동 글자 사이에 글을 끼워 넣어도 사업연도·사업지역·임도종류 연동이 끊기지 않음
  (실측: 「울진군 금강송면」의 「금강송면」 앞 삽입 후에도 지역·종류·연도 변경이 반영)
- 비고 칸을 맨 마지막으로 이동
- 제목·여백·버튼 줄을 공용 템플릿(`createGeneralLayout` · `ui-general-block__actions`)으로 전환,
  화면 전용 제목 스타일 제거
- 헤더의 사용자명·로그아웃(로그인·회원가입) 버튼이 붙어 보이던 것 간격 부여

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:59:22 +09:00
co-authored by Claude Opus 5
parent 9f35ecd2d8
commit 4b5a630f12
4 changed files with 130 additions and 81 deletions
+7
View File
@@ -251,6 +251,13 @@ const SHELL_CSS = `
align-items: center; align-items: center;
gap: var(--spacing-8); gap: var(--spacing-8);
} }
/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다
(2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */
.app-actions__auth {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.app-outlet { .app-outlet {
min-height: calc(100vh - 64px - 56px); min-height: calc(100vh - 64px - 56px);
} }
@@ -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<NameSlot, string>;
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;
},
};
}
+23 -48
View File
@@ -19,8 +19,10 @@ import {
showLoadingOverlay, showLoadingOverlay,
showToast, showToast,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { isBlank } from "@util/common_util_validate"; import { isBlank } from "@util/common_util_validate";
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; 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 { navigateTo } from "../A00_Common/router";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import "./B02_ProjRegister_UI_Style.css"; import "./B02_ProjRegister_UI_Style.css";
@@ -34,19 +36,6 @@ function L(key: keyof typeof ui_locales): string {
* 페이지 진입점 * 페이지 진입점
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
export function renderB02ProjRegister(root: HTMLElement): void { 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 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도 // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도
@@ -137,38 +126,18 @@ export function renderB02ProjRegister(root: HTMLElement): void {
const roadTypeText = (): string => const roadTypeText = (): string =>
roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? "";
/** 이름을 이루는 자동 조각 — 사업연도·사업지역·임도종류 순서다. */ /** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */
const nameParts = (): string[] => [ const nameComposer = createNameComposer();
yearField.input.value.trim(), const nameParts = (): NameParts => ({
regionField.input.value.trim(), year: yearField.input.value.trim(),
roadTypeText(), region: regionField.input.value.trim(),
]; type: roadTypeText(),
let lastParts = nameParts(); });
let nameTouched = false;
nameField.input.addEventListener("input", () => { nameField.input.addEventListener("input", () => {
nameTouched = true; nameComposer.edit(nameField.input.value);
}); });
/**
* 위 세 항목이 바뀌면 이름 칸을 따라 고친다.
*
* 아직 손대지 않은 칸은 통째로 다시 쓰고, 사용자가 고친 뒤에는 **바뀐 조각만 갈아 끼운다**
* — 사이나 끝에 넣은 글이 지워지지 않게 하기 위해서다. 지웠던 조각은 되살리지 않는다.
*/
const syncName = (): void => { const syncName = (): void => {
const next = nameParts(); nameField.input.value = nameComposer.update(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;
}; };
const composedName = (): string => nameField.input.value.trim(); const composedName = (): string => nameField.input.value.trim();
const routeLength = (): number | null => { const routeLength = (): number | null => {
@@ -202,7 +171,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
variant: "filled", variant: "filled",
onClick: onB02_Proj_Submit_Click, onClick: onB02_Proj_Submit_Click,
}); });
submitBtn.classList.add("b02-proj__submit");
const cancelBtn = createButton({ const cancelBtn = createButton({
label: L("Common_Btn_Cancel"), label: L("Common_Btn_Cancel"),
variant: "ghost", variant: "ghost",
@@ -316,8 +284,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
regionField.root, regionField.root,
roadTypeField.root, roadTypeField.root,
nameField.root, nameField.root,
// 비고는 노선 구간 앞에 둔다 (2026-09-06 사용자 지시).
memoField.root,
routeStartField.root, routeStartField.root,
routeEndField.root, routeEndField.root,
clientOrgField.root, clientOrgField.root,
@@ -325,12 +291,21 @@ export function renderB02ProjRegister(root: HTMLElement): void {
pmField.root, pmField.root,
fieldLeadField.root, fieldLeadField.root,
designerField.root, designerField.root,
// 비고는 맨 끝 (2026-09-06 사용자 지시).
memoField.root,
); );
// 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "b02-proj__actions"; actions.className = "ui-general-block__actions b02-proj__actions";
actions.append(cancelBtn, submitBtn); actions.append(cancelBtn, submitBtn);
const card = createCard({ body: [grid, actions], raised: true }); const card = createCard({ body: [grid, actions], raised: true });
page.append(header, card); const layout = createGeneralLayout({
root.append(page); pageClass: "b02-proj",
title: L("B02_Proj_Title"),
subtitle: L("B02_Proj_Subtitle"),
content: card,
});
root.append(layout.root);
} }
+4 -33
View File
@@ -3,31 +3,8 @@
* 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용) * 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용)
* ========================================================================== */ * ========================================================================== */
.b02-proj { /* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는
max-width: 720px; 이 화면에만 필요한 것(2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */
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);
}
/* 2열 필드 그리드 (좁은 화면에서는 1열) */ /* 2열 필드 그리드 (좁은 화면에서는 1열) */
.b02-proj__grid { .b02-proj__grid {
@@ -53,20 +30,14 @@
padding-right: var(--spacing-32); padding-right: var(--spacing-32);
} }
.b02-proj__submit {
align-self: flex-start;
margin-top: var(--spacing-8);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.b02-proj__grid { .b02-proj__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* 취소·등록 버튼 줄 */ /* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */
.b02-proj__actions { .b02-proj__actions {
display: flex;
gap: var(--spacing-8);
justify-content: flex-end; justify-content: flex-end;
margin-top: var(--spacing-16);
} }