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, joinCompany, searchCompanies, addCompanyMember, fetchCompanyMembers, fetchCompanyAssets, updateCompanyAsset, createCompanyAsset, setCompanyLogo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; /** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ const NEW_MEMBER = "__new__"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise): 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"; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: () => modal.remove(), }), 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); } export async function openEditProjectModal( user: DashboardUser, project: ProjectItem, ): Promise { const isUserOnly = user.role === "USER"; // 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정). const [members, assets] = await Promise.all([ fetchCompanyMembers(project.company_id), fetchCompanyAssets(project.company_id), ]); 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 ?? ""), }); 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) })), { value: NEW_MEMBER, text: "+ 신규 등록…" }, ]; const persons: HTMLSelectElement[] = []; const person = (label: string, current: number | null | undefined) => { const field = createSelectField({ label, options: personOptions, value: String(current ?? ""), }); persons.push(field.select); let last = field.select.value; // 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다. field.select.addEventListener("change", () => { if (field.select.value !== NEW_MEMBER) { last = field.select.value; return; } field.select.value = last; openAddMemberModal((member) => { for (const select of persons) { const option = document.createElement("option"); option.value = String(member.id); option.textContent = memberText(member); select.insertBefore(option, select.options[select.options.length - 1]); } field.select.value = String(member.id); last = field.select.value; }); }); 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, ); if (isUserOnly) { name.input.disabled = true; region.input.disabled = true; roadType.input.disabled = true; year.input.disabled = true; length.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, memo.root, clientOrg.root, projectNumber.root, workAmount.root, designDate.root, pm.root, fieldLead.root, designer.root, logo.root, ); const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); openModal(L("B01_Dashboard_EditProject"), [grid], 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, 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, }); showToast(L("B01_Dashboard_Saved"), "success"); }); } 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 { const name = createInputField({ label: L("B01_Dashboard_Table_Name"), value: target.name, required: true, }); const position = createInputField({ label: L("B01_Dashboard_Table_Position"), value: target.position ?? "", }); const department = createInputField({ label: L("B01_Dashboard_Table_Department"), value: target.department ?? "", }); const phoneVal = (target as DashboardUser).phone || ""; const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); // 서명은 사람에게 붙는다 (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, ); }); if (user.role === "ADMIN") { // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 name.input.disabled = true; phone.input.disabled = true; } else if (user.role === "USER" && user.id !== target.id) { name.input.disabled = true; position.input.disabled = true; department.input.disabled = true; phone.input.disabled = true; } openModal( L("B01_Dashboard_EditUser"), [name.root, position.root, department.root, phone.root, signatureSlot], async () => { await updateDashboardUser(target.id, { name: name.input.value.trim(), position: position.input.value.trim() || null, department: department.input.value.trim() || null, phone: phone.input.value.trim() || null, status: 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 { const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; warning.textContent = L("B01_Dashboard_Confirm_DeleteUser"); openModal(L("B01_Dashboard_DeleteUser"), [warning], async () => { await removeCompanyMember(target.id); showToast(L("B01_Dashboard_Saved"), "success"); }); } export function openCreateCompanyModal(): void { const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true }); const number = createInputField({ label: L("B01_Dashboard_Field_BusinessNumber"), required: true, }); const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") }); // 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다. const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" }); logo.input.type = "file"; logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg"; openModal( L("B01_Dashboard_Modal_CreateCompany"), [name.root, number.root, address.root, owner.root, logo.root], async () => { if (!name.input.value.trim() || !number.input.value.trim()) return; const created = await createCompany({ 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, }); const file = logo.input.files?.[0]; if (file && created?.company_id) { const form = new FormData(); form.append("kind", "LOGO"); form.append("label", `${name.input.value.trim()} 로고`); 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"); }, ); } 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-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다. */ // ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`). // 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것. export function openAddMemberModal(onCreated?: (member: Member) => void): void { const email = createInputField({ label: L("B01_Dashboard_Field_MemberEmail"), type: "email", required: true, }); const name = createInputField({ label: L("B01_Dashboard_Table_Name") }); const position = createInputField({ label: L("B01_Dashboard_Table_Position") }); const department = createInputField({ label: L("B01_Dashboard_Table_Department") }); openModal( L("B01_Dashboard_Modal_AddMember"), [email.root, name.root, position.root, department.root], async () => { if (!email.input.value.trim()) return; const created = await addCompanyMember(email.input.value.trim(), { name: name.input.value.trim() || undefined, position: position.input.value.trim() || null, department: department.input.value.trim() || null, }); showToast(L("B01_Dashboard_Saved"), "success"); if (created?.member) onCreated?.(created.member); }, ); }