- B01 프로젝트 수정: 과업책임자·분야별책임자·설계자를 회사 구성원 목록(select)에서 지정. 회사 로고·설계자 서명 칸은 누르면 등록 목록 모달(선택·삭제·신규 추가 업로드). - 백엔드: `company_assets`(013) CRUD 라우트 신설 — 목록·업로드(Form+File, 2MB·확장자 검사)·수정·소프트 삭제·파일 서빙. `_scope_company` 로 SYSTEM_ADMIN 만 타 회사 지정, `_check_project_refs` 로 담당자·자산이 같은 회사·같은 종류인지 검사(400). - projects 조회·수정에 pm/field_lead/designer/logo_asset/signature_asset 5칸 합류. 중복 3곳을 `_project_rows` 로 묶어 700줄 제한 유지. - B07 표제란: 로고·서명 출처를 `companies.logo_path`/`users.signature_path` 에서 프로젝트가 고른 `company_assets` 로 전환(삭제된 자산은 그림째 제외). - 검증: pytest 143 통과(신규 4), tsc·ruff·prettier 통과. 공용 브라우저 사용자 경로 (업로드→선택→저장→재열기) 및 종단도 API 표제란 글자 4건·그림 2건 수치 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
376 lines
12 KiB
TypeScript
376 lines
12 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,
|
|
type DashboardUser,
|
|
type ProjectItem,
|
|
type Member,
|
|
} from "./B01_Dashboard_Api_Fetch";
|
|
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
|
|
|
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 personOptions = [
|
|
{ value: "", text: "(미지정)" },
|
|
...members.map((member) => ({
|
|
value: String(member.id),
|
|
text: member.position ? `${member.name} (${member.position})` : member.name,
|
|
})),
|
|
];
|
|
const person = (label: string, current: number | null | undefined) =>
|
|
createSelectField({ label, options: personOptions, value: String(current ?? "") });
|
|
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,
|
|
);
|
|
const signature = createAssetField(
|
|
"설계자 서명 (도면 표제란)",
|
|
"SIGNATURE",
|
|
assets,
|
|
project.signature_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,
|
|
signature.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(),
|
|
signature_asset_id: signature.value(),
|
|
});
|
|
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 });
|
|
|
|
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],
|
|
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") });
|
|
|
|
openModal(
|
|
L("B01_Dashboard_Modal_CreateCompany"),
|
|
[name.root, number.root, address.root, owner.root],
|
|
async () => {
|
|
if (!name.input.value.trim() || !number.input.value.trim()) return;
|
|
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,
|
|
});
|
|
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,
|
|
);
|
|
}
|
|
|
|
export function openAddMemberModal(): void {
|
|
const email = createInputField({
|
|
label: L("B01_Dashboard_Field_MemberEmail"),
|
|
type: "email",
|
|
required: true,
|
|
});
|
|
openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => {
|
|
if (!email.input.value.trim()) return;
|
|
await addCompanyMember(email.input.value.trim());
|
|
showToast(L("B01_Dashboard_Saved"), "success");
|
|
});
|
|
}
|