B01 - 팀원 등록을 계정 대리 생성에서 「소속 없는 기존 가입자 검색·선택」으로 교체, 미가입자는 안내 메일만 발송 - 사용자 계정 삭제 API 신설, 삭제 모달에서 회사 제외/계정 삭제 선택, 관리자 수정 모달에 활성·비활성 상태 지정 - 기본정보 폼과 사용자 수정 폼을 공용 입력칸 한 벌로 통일 (이름 > 직급 > 이메일 > 부서 > 전화) - 프로젝트 담당자 선택의 신규 등록 항목 제거 B02 - 프로젝트명 자동 조합 (사업연도 + 사업지역 + 임도종류 + 직접 입력값) 및 미리보기 표시 - 예상 연장 입력칸 제거, 노선 종료 누가거리 제목에 구간 연장 표기, 표지 값은 프로젝트명·연장에서 파생 - 취소 버튼 추가 (대시보드 복귀), 담당자 기본값을 생성자로 지정 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* B02_ProjRegister_UI_Page.ts
|
|
* 로그인 후 02: 프로젝트 등록 (신규 임도 설계 프로젝트 기본 정보 입력)
|
|
*
|
|
* 제약 준수 (frontend.md):
|
|
* - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지, §3).
|
|
* - 색상/간격은 theme.css 변수 + 공통 컴포넌트만 사용 (§1, §2).
|
|
* - 이벤트 핸들러는 onB02_[기능]_[액션] 명명 (§4).
|
|
* - 백엔드 전송 전 1차 유효성 검사 (§4).
|
|
* ========================================================================== */
|
|
|
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
createCard,
|
|
hideLoadingOverlay,
|
|
showLoadingOverlay,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { isBlank } from "@util/common_util_validate";
|
|
import {
|
|
fetchCompanyAssets,
|
|
fetchCompanyMembers,
|
|
fetchDashboardMe,
|
|
fetchUserCompany,
|
|
} from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
|
import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker";
|
|
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";
|
|
|
|
/** locale 헬퍼 */
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 페이지 진입점
|
|
* -------------------------------------------------------------------------- */
|
|
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 사용자 확정). 아래 미리보기 줄이 저장될 이름 그대로다.
|
|
const nameField = createInputField({
|
|
label: "프로젝트명 (직접 입력 부분)",
|
|
placeholder: "예: 가리왕산지구",
|
|
required: true,
|
|
});
|
|
const namePreview = document.createElement("p");
|
|
namePreview.className = "b02-proj__preview";
|
|
const regionField = createInputField({
|
|
label: L("B02_Proj_Field_Region"),
|
|
placeholder: L("B02_Proj_Field_Region_Placeholder"),
|
|
required: true,
|
|
});
|
|
// 임도 종류 3종 — 현행 규칙 기준(지선임도 폐지, 계류보전은 사방사업 구분이라 제외).
|
|
// 이 값이 B05 계획선의 법정 기준(설계속도 선택지·종단기울기 상한)을 정한다
|
|
// (2026-08-19 사용자 확정).
|
|
const roadTypeField = createSelectField({
|
|
label: L("B02_Proj_Field_RoadType"),
|
|
options: [
|
|
{ value: "main", text: L("B02_Proj_RoadType_Main") },
|
|
{ value: "fire", text: L("B02_Proj_RoadType_Fire") },
|
|
{ value: "work", text: L("B02_Proj_RoadType_Work") },
|
|
],
|
|
});
|
|
const currentYear = new Date().getFullYear();
|
|
const yearField = createInputField({
|
|
label: L("B02_Proj_Field_Year"),
|
|
type: "number",
|
|
value: String(currentYear),
|
|
min: 2000,
|
|
max: currentYear + 5,
|
|
});
|
|
// 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
|
|
// 둘 다 비우면 전 구간을 쓴다.
|
|
const routeStartField = createInputField({
|
|
label: "노선 시작 누가거리 (m)",
|
|
placeholder: "비우면 처음부터",
|
|
type: "number",
|
|
min: 0,
|
|
});
|
|
const routeEndField = createInputField({
|
|
label: "노선 종료 누가거리 (m)",
|
|
placeholder: "비우면 끝까지",
|
|
type: "number",
|
|
min: 0,
|
|
});
|
|
const memoField = createInputField({
|
|
label: L("B02_Proj_Field_Memo"),
|
|
placeholder: L("B02_Proj_Field_Memo_Placeholder"),
|
|
type: "text",
|
|
});
|
|
|
|
// 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다
|
|
// (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다.
|
|
const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" });
|
|
// 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과
|
|
// 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다.
|
|
const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" });
|
|
|
|
const person = (label: string) =>
|
|
createSelectField({ label, options: [{ value: "", text: "(미지정)" }] });
|
|
const pmField = person("과업책임자 (도면 표제란)");
|
|
const fieldLeadField = person("분야별책임자 (도면 표제란)");
|
|
const designerField = person("설계자 (도면 표제란)");
|
|
const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
|
|
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다.
|
|
const logoSlot = document.createElement("div");
|
|
let logoValue: () => number | null = () => null;
|
|
|
|
void (async () => {
|
|
const [me, company] = await Promise.all([
|
|
fetchDashboardMe(),
|
|
fetchUserCompany().catch(() => null),
|
|
]);
|
|
const [members, assets] = await Promise.all([
|
|
fetchCompanyMembers().catch(() => []),
|
|
fetchCompanyAssets().catch(() => []),
|
|
]);
|
|
const memberText = (member: (typeof members)[number]) =>
|
|
member.position ? `${member.name} (${member.position})` : member.name;
|
|
const addOption = (select: HTMLSelectElement, value: string, text: string) => {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = text;
|
|
select.append(option);
|
|
};
|
|
for (const select of personSelects) {
|
|
for (const member of members) addOption(select, String(member.id), memberText(member));
|
|
// 담당자 기본값은 만든 사람 (2026-09-06 사용자 확정). 계정을 그 자리에서 만드는
|
|
// 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다.
|
|
if (members.some((member) => member.id === me.id)) select.value = String(me.id);
|
|
}
|
|
if (company) {
|
|
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
|
|
const logo = createAssetField(
|
|
"회사 로고 (도면 표제란)",
|
|
"LOGO",
|
|
assets,
|
|
company.logo_asset_id ?? null,
|
|
company.id,
|
|
me,
|
|
);
|
|
logoValue = logo.value;
|
|
logoSlot.append(logo.root);
|
|
}
|
|
})();
|
|
|
|
const roadTypeText = (): string =>
|
|
roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? "";
|
|
const composedName = (): string =>
|
|
[
|
|
yearField.input.value.trim(),
|
|
regionField.input.value.trim(),
|
|
roadTypeText(),
|
|
nameField.input.value.trim(),
|
|
]
|
|
.filter((part) => part.length > 0)
|
|
.join(" ");
|
|
const routeLength = (): number | null => {
|
|
const start = isBlank(routeStartField.input.value)
|
|
? null
|
|
: Number.parseFloat(routeStartField.input.value);
|
|
const end = isBlank(routeEndField.input.value)
|
|
? null
|
|
: Number.parseFloat(routeEndField.input.value);
|
|
if (end === null || !Number.isFinite(end)) return null;
|
|
const from = start !== null && Number.isFinite(start) ? start : 0;
|
|
return end > from ? end - from : null;
|
|
};
|
|
const refresh = (): void => {
|
|
namePreview.textContent = `저장될 이름: ${composedName() || "(입력 대기)"}`;
|
|
const length = routeLength();
|
|
routeEndField.root.querySelector(".ui-field__label")!.textContent =
|
|
length === null
|
|
? "노선 종료 누가거리 (m)"
|
|
: `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`;
|
|
};
|
|
for (const field of [nameField, regionField, yearField, routeStartField, routeEndField]) {
|
|
field.input.addEventListener("input", refresh);
|
|
}
|
|
roadTypeField.select.addEventListener("change", refresh);
|
|
refresh();
|
|
|
|
const submitBtn = createButton({
|
|
label: L("B02_Proj_Submit"),
|
|
variant: "filled",
|
|
onClick: onB02_Proj_Submit_Click,
|
|
});
|
|
submitBtn.classList.add("b02-proj__submit");
|
|
const cancelBtn = createButton({
|
|
label: L("Common_Btn_Cancel"),
|
|
variant: "ghost",
|
|
onClick: function onB02_Proj_Cancel_Click() {
|
|
navigateTo(ROUTES.B01_ACCOUNT);
|
|
},
|
|
});
|
|
|
|
const idOrNull = (select: HTMLSelectElement): number | null =>
|
|
select.value ? Number(select.value) : null;
|
|
|
|
async function onB02_Proj_Submit_Click(): Promise<void> {
|
|
nameField.setError();
|
|
regionField.setError();
|
|
yearField.setError();
|
|
routeStartField.setError();
|
|
routeEndField.setError();
|
|
|
|
// 1차 유효성: 필수값 검사
|
|
let hasError = false;
|
|
const projectYear = Number.parseInt(yearField.input.value, 10);
|
|
// 예상 연장은 따로 받지 않는다 — 노선 구간 길이가 곧 연장이다 (2026-09-06 사용자 확정).
|
|
const estimatedLength = routeLength();
|
|
if (isBlank(nameField.input.value)) {
|
|
nameField.setError(L("B02_Proj_Error_Required"));
|
|
hasError = true;
|
|
}
|
|
if (isBlank(regionField.input.value)) {
|
|
regionField.setError(L("B02_Proj_Error_Required"));
|
|
hasError = true;
|
|
}
|
|
if (!Number.isInteger(projectYear) || projectYear < 2000 || projectYear > 2100) {
|
|
yearField.setError(L("Common_Validation_NumberRange"));
|
|
hasError = true;
|
|
}
|
|
const routeStart = isBlank(routeStartField.input.value)
|
|
? null
|
|
: Number.parseFloat(routeStartField.input.value);
|
|
const routeEnd = isBlank(routeEndField.input.value)
|
|
? null
|
|
: Number.parseFloat(routeEndField.input.value);
|
|
if (routeStart !== null && (!Number.isFinite(routeStart) || routeStart < 0)) {
|
|
routeStartField.setError(L("Common_Validation_NumberRange"));
|
|
hasError = true;
|
|
}
|
|
if (routeEnd !== null && (!Number.isFinite(routeEnd) || routeEnd < 0)) {
|
|
routeEndField.setError(L("Common_Validation_NumberRange"));
|
|
hasError = true;
|
|
}
|
|
// 시작이 종료보다 뒤면 남는 구간이 없다 — 서버도 같은 규칙으로 막는다.
|
|
if (routeStart !== null && routeEnd !== null && routeStart >= routeEnd) {
|
|
routeEndField.setError("종료 누가거리는 시작보다 커야 합니다.");
|
|
hasError = true;
|
|
}
|
|
if (hasError) return;
|
|
|
|
showLoadingOverlay();
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/b02/project`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
name: composedName(),
|
|
region: regionField.input.value.trim(),
|
|
road_type: roadTypeField.select.value,
|
|
project_year: projectYear,
|
|
estimated_length_m: estimatedLength,
|
|
route_start_m: routeStart,
|
|
route_end_m: routeEnd,
|
|
memo: memoField.input.value.trim() || null,
|
|
client_org: clientOrgField.input.value.trim() || null,
|
|
// 표지 값은 프로젝트명·연장에서 끌어 쓴다 (2026-09-06 사용자 확정).
|
|
project_number: composedName(),
|
|
work_amount:
|
|
estimatedLength === null ? null : `L=${(estimatedLength / 1000).toFixed(2)}km`,
|
|
design_date: designDateField.input.value || null,
|
|
pm_user_id: idOrNull(pmField.select),
|
|
field_lead_user_id: idOrNull(fieldLeadField.select),
|
|
designer_user_id: idOrNull(designerField.select),
|
|
logo_asset_id: logoValue(),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let detail = L("Common_Status_Error");
|
|
try {
|
|
const errorBody = (await response.json()) as { detail?: string; message?: string };
|
|
detail = errorBody.detail ?? errorBody.message ?? detail;
|
|
} catch {
|
|
detail = `${detail} (${response.status})`;
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
const data = (await response.json()) as { project_id: string };
|
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, data.project_id);
|
|
showToast(L("B02_Proj_Success"), "success");
|
|
navigateTo(ROUTES.B03_FILE_INPUT);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : L("Common_Status_Error");
|
|
showToast(message, "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "b02-proj__grid";
|
|
grid.append(
|
|
yearField.root,
|
|
regionField.root,
|
|
roadTypeField.root,
|
|
nameField.root,
|
|
namePreview,
|
|
routeStartField.root,
|
|
routeEndField.root,
|
|
memoField.root,
|
|
clientOrgField.root,
|
|
designDateField.root,
|
|
pmField.root,
|
|
fieldLeadField.root,
|
|
designerField.root,
|
|
logoSlot,
|
|
);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "b02-proj__actions";
|
|
actions.append(cancelBtn, submitBtn);
|
|
const card = createCard({ body: [grid, actions], raised: true });
|
|
page.append(header, card);
|
|
root.append(page);
|
|
}
|