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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user