Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Modals.ts
T
eomsangdonandClaude Opus 5 3a36af9a2c feat(B01): 서명은 사람 계정에, 회사 로고는 회사 등록 단계로 재배치
사용자 확정(2026-09-02) — 이름이 들어가는 자리는 서명도 받고, 담당자 선택에 신규 등록
항목을 두며, 회사 로고는 회사 등록 단계에서 받아 이후 변경.

- 014_company_logo.sql: companies.logo_asset_id 추가 (서명은 company_assets.user_id 로
  이미 표현되어 컬럼 없음). 공유 DB 적용 완료
- 사용자 수정 모달에 「서명 (도면 표제란)」 칸 — createAssetField 에 owner(주인 못박기)·
  onChange(즉시 반영) 추가로 재사용, 주인 고정 시 물리기 체크 잠금
- 담당자 select 3개에 「+ 신규 등록…」 — 계정 생성 모달 뒤 세 select 에 항목 삽입·자동 선택
- POST /admin/members 가 이름·직위·부서 수신, 계정 없으면 status=PENDING 으로 생성
  (로그인 불가 비밀번호). 700줄 제한으로 B01_Dashboard_Repository_Members.py 분리
- 회사 등록 모달에 로고 파일 칸, PUT /company/logo 신설, 회사 패널·회사 목록에서 변경
- 프로젝트 수정 모달의 「설계자 서명」 칸 제거 — signature_asset_id 는 null 로 고정 저장
- 검증(5174 실조작): 신규 등록 구성원 2→3(id 6 PENDING)·select 자동 선택, 서명 자산
  [4,'검증신규 서명',user_id 6] 생성, 회사 logo_asset_id None→1, 검증 자산·계정 정리.
  pytest 143 passed, tsc·ruff·prettier 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:57:16 +09:00

459 lines
16 KiB
TypeScript

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>): 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<void> {
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);
},
);
}