import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, showToast, showLoadingOverlay, hideLoadingOverlay, } from "@ui/ui_template_elements"; import { updateProject, deleteProject, changeUserRole, updateDashboardUser, removeCompanyMember, createCompany, updateCompany, joinCompany, searchCompanies, addCompanyMember, searchMemberCandidates, inviteMember, deleteDashboardUser, fetchCompanyMembers, fetchCompanyAssets, fetchUserCompany, updateCompanyAsset, createCompanyAsset, setCompanyLogo, type CompanyInfo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview"; import { attachModalDismiss, buildUserFields, type ModalDismissHandle, } from "./B01_Dashboard_UI_Common"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function openModal( title: string, body: HTMLElement[], onConfirm: () => Promise, options: { extra?: () => string } = {}, ): void { const modal = document.createElement("div"); modal.className = "b01-dashboard__modal"; const panel = document.createElement("div"); panel.className = "b01-dashboard__modal-panel"; const heading = document.createElement("h3"); heading.className = "b01-dashboard__modal-title"; heading.textContent = title; const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; // 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다. let dismiss: ModalDismissHandle | null = null; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: () => void dismiss?.tryClose(), }), createButton({ label: L("Common_Btn_Confirm"), onClick: async () => { showLoadingOverlay(); try { await onConfirm(); modal.remove(); window.dispatchEvent(new HashChangeEvent("hashchange")); } catch (error) { showToast( error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error", ); } finally { hideLoadingOverlay(); } }, }), ); panel.append(heading, ...body, actions); modal.append(panel); document.body.append(modal); dismiss = attachModalDismiss(modal, panel, { extra: options.extra }); } export async function openEditProjectModal( user: DashboardUser, project: ProjectItem, ): Promise { // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). const isUserOnly = user.role === "USER" && !(project.member_user_ids ?? []).includes(user.id); // 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정). // 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시). const [members, assets, company] = await Promise.all([ fetchCompanyMembers(project.company_id), fetchCompanyAssets(project.company_id), fetchUserCompany().catch(() => null), ]); // 남의 회사 프로젝트(시스템관리자)에서는 내 회사 로고를 기본으로 내밀지 않는다. const sameCompany = company != null && company.id === project.company_id; const companyLogo = (sameCompany && assets.find((asset) => asset.kind === "LOGO" && asset.id === company.logo_asset_id)) || null; const name = createInputField({ label: L("B01_Dashboard_Table_Project"), value: project.name, required: true, }); const region = createInputField({ label: L("B01_Dashboard_Table_Region"), value: project.region ?? "", }); const roadType = createInputField({ label: "임도 종류", value: project.road_type ?? "" }); const year = createInputField({ label: "사업 연도", type: "number", value: String(project.project_year ?? ""), }); const length = createInputField({ label: "예상 연장 (m)", type: "number", value: String(project.estimated_length_m ?? ""), }); // 계획노선 사용 범위 — 등록(B02)에서 받은 값을 여기서도 고친다 (2026-09-04 사용자 지시). const routeStart = createInputField({ label: "노선 시작 누가거리 (m)", type: "number", value: project.route_start_m == null ? "" : String(project.route_start_m), placeholder: "비우면 처음부터", }); const routeEnd = createInputField({ label: "노선 종료 누가거리 (m)", type: "number", value: project.route_end_m == null ? "" : String(project.route_end_m), placeholder: "비우면 끝까지", }); const memo = createInputField({ label: "비고", value: project.memo ?? "" }); // 도면 표제란·표지에 그대로 실리는 값 — 프로그램이 지어낼 수 없어 여기서 받는다. const clientOrg = createInputField({ label: "시행청 (도면 표제란)", value: project.client_org ?? "", }); const projectNumber = createInputField({ label: "연도·기번 (표지)", value: project.project_number ?? "", placeholder: "예: 2026년 간선임도(기번3-울진.대흥)", }); const workAmount = createInputField({ label: "사업량 (표지)", value: project.work_amount ?? "", placeholder: "예: L=2.14km", }); // 설계일자는 확정일 자동이 아니라 사용자가 지정한다 (2026-09-02 사용자 확정). const designDate = createInputField({ label: "설계일자 (도면 표제란)", type: "date", value: (project.design_date ?? "").slice(0, 10), }); const memberText = (member: Member) => member.position ? `${member.name} (${member.position})` : member.name; const personOptions = [ { value: "", text: "(미지정)" }, ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), ]; const persons: HTMLSelectElement[] = []; // 담당자는 이미 등록된 팀원 중에서만 고른다 (2026-09-06 사용자 확정) — // 이 자리에서 계정을 만드는 「신규 등록…」은 없앴다. const person = (label: string, current: number | null | undefined) => { const field = createSelectField({ label, options: personOptions, value: String(current ?? ""), }); persons.push(field.select); return field; }; const pm = person("과업책임자 (도면 표제란)", project.pm_user_id); const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id); const designer = person("설계자 (도면 표제란)", project.designer_user_id); const logo = createAssetField( "회사 로고 (도면 표제란)", "LOGO", assets, project.logo_asset_id, project.company_id, user, { // 프로젝트가 안 고르면 도면에는 회사 로고가 실린다 — 저장값은 계속 비워 둬 연결을 유지한다. fallback: sameCompany ? { asset: companyLogo, prefix: "회사 기본 로고", missingNote: "(없음) — 회사 로고 미지정 (회사 정보 화면의 「로고 지정…」)", } : undefined, }, ); if (isUserOnly) { name.input.disabled = true; region.input.disabled = true; roadType.input.disabled = true; year.input.disabled = true; length.input.disabled = true; routeStart.input.disabled = true; routeEnd.input.disabled = true; memo.input.disabled = true; clientOrg.input.disabled = true; projectNumber.input.disabled = true; workAmount.input.disabled = true; designDate.input.disabled = true; pm.select.disabled = true; fieldLead.select.disabled = true; designer.select.disabled = true; } // 칸이 많아 두 줄 격자로 — 첫 칸(공사명)만 가로로 다 쓴다. const grid = document.createElement("div"); grid.className = "b01-dashboard__form-grid"; grid.append( name.root, region.root, roadType.root, year.root, length.root, routeStart.root, routeEnd.root, memo.root, clientOrg.root, projectNumber.root, workAmount.root, designDate.root, pm.root, fieldLead.root, designer.root, logo.root, ); // 참여자 — 도면 표제란 3역할과 별개로 설계에 손대는 사람들 (2026-09-06 사용자 확정). const memberBox = document.createElement("div"); memberBox.className = "b01-dashboard__members"; const memberLabel = document.createElement("p"); memberLabel.className = "b01-dashboard__modal-text"; memberLabel.textContent = "참여자 (고른 사람은 이 프로젝트를 수정할 수 있음)"; memberBox.append(memberLabel); const memberChecks: HTMLInputElement[] = []; for (const member of members) { const row = document.createElement("label"); row.className = "b01-dashboard__member-row"; const check = document.createElement("input"); check.type = "checkbox"; check.value = String(member.id); check.checked = (project.member_user_ids ?? []).includes(member.id); check.disabled = isUserOnly; memberChecks.push(check); row.append(check, document.createTextNode(` ${memberText(member)}`)); memberBox.append(row); } const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); // 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다. const editProjectExtra = (): string => String(logo.value() ?? ""); openModal( L("B01_Dashboard_EditProject"), [grid, memberBox], async () => { await updateProject(project.id, { name: name.input.value.trim(), region: region.input.value.trim() || null, road_type: roadType.input.value.trim() || null, project_year: year.input.value ? Number(year.input.value) : null, estimated_length_m: length.input.value ? Number(length.input.value) : null, route_start_m: routeStart.input.value ? Number(routeStart.input.value) : null, route_end_m: routeEnd.input.value ? Number(routeEnd.input.value) : null, memo: memo.input.value.trim() || null, status: project.status, client_org: clientOrg.input.value.trim() || null, project_number: projectNumber.input.value.trim() || null, work_amount: workAmount.input.value.trim() || null, design_date: designDate.input.value || null, pm_user_id: userId(pm.select), field_lead_user_id: userId(fieldLead.select), designer_user_id: userId(designer.select), logo_asset_id: logo.value(), // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. signature_asset_id: null, member_user_ids: memberChecks.filter((c) => c.checked).map((c) => Number(c.value)), }); showToast(L("B01_Dashboard_Saved"), "success"); }, { extra: editProjectExtra }, ); } export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void { const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; // 하드 삭제 모드에서는 업로드 원본까지 사라진다 — 문구를 바꿔 실수로 날리는 걸 막는다. warning.textContent = user.project_delete_hard ? L("B01_Dashboard_Confirm_DeleteProject_Hard") : L("B01_Dashboard_Confirm_DeleteProject"); openModal(L("B01_Dashboard_DeleteProject"), [warning], async () => { await deleteProject(project.id); showToast(L("B01_Dashboard_Saved"), "success"); }); } export function openEditUserModal(user: DashboardUser, target: Member | DashboardUser): void { // 기본정보 폼과 같은 칸 한 벌을 쓴다 (2026-09-06 사용자 지시). const fields = buildUserFields({ name: target.name, email: target.email, position: target.position, department: target.department, phone: (target as DashboardUser).phone, }); const isAdmin = user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; // 관리자는 계정을 비활성화할 수 있다 (2026-09-06 사용자 지시) — 비활성 계정은 다음 // 요청에서 세션이 끊긴다. const statusField = createSelectField({ label: L("B01_Dashboard_Table_Status"), options: [ { value: "ACTIVE", text: "활성" }, { value: "INACTIVE", text: "비활성" }, ], value: target.status === "INACTIVE" ? "INACTIVE" : "ACTIVE", }); // 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를 // 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다. const signatureSlot = document.createElement("div"); const companyId = (target as DashboardUser).company_id ?? user.company_id; void fetchCompanyAssets(companyId).then((assets) => { const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id); signatureSlot.append( createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, companyId, user, { owner: { id: target.id, name: target.name }, onChange: async (assetId) => { if (assetId === null) return; const picked = assets.find((asset) => asset.id === assetId); if (picked) await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); }, }).root, ); }); const rows = isAdmin ? [fields.grid, statusField.root, signatureSlot] : [fields.grid, signatureSlot]; openModal(L("B01_Dashboard_EditUser"), rows, async () => { if (!fields.validate()) return; await updateDashboardUser(target.id, { ...fields.values(), // 상태는 관리자만 바꾼다. 그 밖에는 지금 값을 그대로 되돌려 보낸다. status: isAdmin ? statusField.select.value : target.status, }); showToast(L("B01_Dashboard_Saved"), "success"); }); } export function openChangeRoleModal(target: Member | DashboardUser): void { const roleField = createSelectField({ label: L("B01_Dashboard_Table_Role"), options: [ { value: "USER", text: "USER" }, { value: "ADMIN", text: "ADMIN" }, ], value: target.role, }); openModal(L("B01_Dashboard_ChangeRole"), [roleField.root], async () => { await changeUserRole(target.id, roleField.select.value); showToast(L("B01_Dashboard_Saved"), "success"); }); } export function openDeleteUserModal(target: Member | DashboardUser): void { // 두 가지가 다르다 — 회사에서 빼면 계정은 남고(무소속), 계정 삭제는 로그인 자체가 막힌다 // (2026-09-06 사용자 확정). const mode = createSelectField({ label: "처리 방식", options: [ { value: "REMOVE", text: "회사에서 빼기 (계정은 남음)" }, { value: "DELETE", text: "계정 삭제 (로그인 불가)" }, ], value: "REMOVE", }); const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; warning.textContent = L("B01_Dashboard_Confirm_DeleteUser"); openModal(L("B01_Dashboard_DeleteUser"), [mode.root, warning], async () => { if (mode.select.value === "DELETE") await deleteDashboardUser(target.id); else await removeCompanyMember(target.id); showToast(L("B01_Dashboard_Saved"), "success"); }); } /** 회사 등록·수정이 함께 쓰는 입력칸 — 순서는 사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소. */ function companyFields(company?: CompanyInfo): { rows: HTMLElement[]; values: () => { name: string; business_registration_number: string; business_address: string | null; business_owner: string | null; }; logoFile: () => File | undefined; valid: () => boolean; } { const number = createInputField({ label: L("B01_Dashboard_Field_BusinessNumber"), value: company?.business_registration_number ?? "", required: true, }); const name = createInputField({ label: L("B01_Dashboard_Table_Company"), value: company?.name ?? "", required: true, }); const owner = createInputField({ label: L("B01_Dashboard_Field_Owner"), value: company?.business_owner ?? "", }); const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" }); logo.input.type = "file"; logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg"; const address = createInputField({ label: L("B01_Dashboard_Field_Address"), value: company?.business_address ?? "", }); // 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시). const map = buildAddressMap(); const mapBtn = createButton({ label: "지도 확인", variant: "ghost", onClick: async function onB01_Company_Map_Click() { await map.show(address.input.value.trim()); }, }); const addressRow = document.createElement("div"); addressRow.append(address.root, mapBtn, map.root); return { rows: [number.root, name.root, owner.root, logo.root, addressRow], values: () => ({ name: name.input.value.trim(), business_registration_number: number.input.value.trim(), business_address: address.input.value.trim() || null, business_owner: owner.input.value.trim() || null, }), logoFile: () => logo.input.files?.[0], valid: () => Boolean(name.input.value.trim() && number.input.value.trim()), }; } export function openCreateCompanyModal(): void { const fields = companyFields(); // 같은 회사를 두 번 만들지 않게, 등록 전에 이미 있는 회사를 찾아 보여 준다 // (2026-09-06 사용자 지시). const matches = document.createElement("div"); matches.className = "b01-dashboard__actions"; const findBtn = createButton({ label: "이미 있는 회사인지 찾기", variant: "ghost", onClick: async function onB01_Company_Duplicate_Click() { matches.innerHTML = ""; const values = fields.values(); const query = values.business_registration_number || values.name; if (!query) return; const found = await searchCompanies(query); if (found.length === 0) { const empty = document.createElement("p"); empty.className = "b01-dashboard__modal-text"; empty.textContent = "같은 회사가 없습니다. 그대로 등록하십시오."; matches.append(empty); return; } for (const company of found) { matches.append( createButton({ label: `${company.name} — ${L("B01_Dashboard_JoinCompany")}`, variant: "ghost", onClick: async () => { showLoadingOverlay(); try { await joinCompany(company.id); showToast(L("B01_Dashboard_Saved"), "success"); } catch { showToast("요청 실패", "error"); } finally { hideLoadingOverlay(); } }, }), ); } }, }); openModal( L("B01_Dashboard_Modal_CreateCompany"), [...fields.rows, findBtn, matches], async () => { if (!fields.valid()) return; const values = fields.values(); const created = await createCompany(values); const file = fields.logoFile(); if (file && created?.company_id) { const form = new FormData(); form.append("kind", "LOGO"); form.append("label", `${values.name} 로고`); form.append("file", file); form.append("company_id", String(created.company_id)); const assetId = await createCompanyAsset(form); await setCompanyLogo(assetId, created.company_id); } showToast(L("B01_Dashboard_Saved"), "success"); }, ); } /** 회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체. */ export function openEditCompanyModal(company: CompanyInfo): void { const fields = companyFields(company); openModal("회사 정보 수정", fields.rows, async () => { if (!fields.valid()) return; const values = fields.values(); await updateCompany(values, company.id); const file = fields.logoFile(); if (file) { const form = new FormData(); form.append("kind", "LOGO"); form.append("label", `${values.name} 로고`); form.append("file", file); form.append("company_id", String(company.id)); const assetId = await createCompanyAsset(form); await setCompanyLogo(assetId, company.id); } showToast(L("B01_Dashboard_Saved"), "success"); }); } export function openFindCompanyModal(): void { const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true }); const results = document.createElement("div"); results.className = "b01-dashboard__actions"; const search = createButton({ label: L("Common_Btn_Search"), variant: "ghost", onClick: async function onB01_Company_Search_Click() { results.innerHTML = ""; const companies = await searchCompanies(query.input.value.trim()); for (const company of companies) { results.append( createButton({ label: `${company.name} ${L("B01_Dashboard_JoinCompany")}`, variant: "ghost", onClick: async () => { showLoadingOverlay(); try { await joinCompany(company.id); showToast(L("B01_Dashboard_Saved"), "success"); } catch (e) { showToast("요청 실패", "error"); } finally { hideLoadingOverlay(); } }, }), ); } }, }); openModal( L("B01_Dashboard_Modal_FindCompany"), [query.root, search, results], async () => undefined, ); } /** * 팀원 등록 — 이미 가입한 사람 중 **소속이 없는 사람**만 골라 붙인다 * (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는 * 안내 메일만 보낸다. */ export function openAddMemberModal(onCreated?: (member: Member) => void): void { const query = createInputField({ label: "이름 또는 이메일로 찾기", placeholder: "두 글자 이상", }); const results = document.createElement("div"); results.className = "b01-dashboard__actions"; let picked: Member | null = null; const search = createButton({ label: L("Common_Btn_Search"), variant: "ghost", onClick: async function onB01_Member_Search_Click() { results.innerHTML = ""; const text = query.input.value.trim(); if (text.length < 2) { query.setError("두 글자 이상 입력하십시오."); return; } query.setError(); const users = await searchMemberCandidates(text); if (users.length === 0) { const empty = document.createElement("p"); empty.className = "b01-dashboard__modal-text"; empty.textContent = "소속 없는 가입자가 없습니다. 아직 가입 전이면 안내 메일을 보내십시오."; results.append(empty); return; } for (const candidate of users) { results.append( createButton({ label: `${candidate.name} (${candidate.email})`, variant: picked?.id === candidate.id ? "filled" : "ghost", onClick: () => { picked = candidate; query.input.value = `${candidate.name} (${candidate.email})`; results.innerHTML = ""; }, }), ); } }, }); const invite = createButton({ label: "가입 안내 메일 보내기", variant: "ghost", onClick: async function onB01_Member_Invite_Click() { const text = query.input.value.trim(); if (!text.includes("@")) { query.setError("메일을 보낼 이메일 주소를 입력하십시오."); return; } query.setError(); await inviteMember(text); showToast("안내 메일을 보냈습니다.", "success"); }, }); const bar = document.createElement("div"); bar.className = "b01-dashboard__actions"; bar.append(search, invite); openModal(L("B01_Dashboard_Modal_AddMember"), [query.root, bar, results], async () => { if (!picked) { query.setError("등록할 사람을 먼저 고르십시오."); throw new Error("등록할 사람을 먼저 고르십시오."); } const created = await addCompanyMember(picked.id); showToast(L("B01_Dashboard_Saved"), "success"); if (created?.member) onCreated?.(created.member); }); }