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