Files
Aislo/B01_Dashboard/B01_Dashboard_UI_View_People.ts
T
eomsangdonandClaude Sonnet 5.5 7e34152de8 feat(B01): 받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값 세부 페이지 (549-1)
- 새 페이지 다섯(View_Assigned · Temp · People · Company · CompanyDefaults) · 등록부 다섯 줄 교체
- 표 줄 40 · 세로선 없음 · 줄 끝 ⋯ · 한글 배지 · 빈 상태 · 사람 탭 가입 요청 수 배지 · 보관함 남은 날 막대(7 일 안 빨강)
- 옛 구역 정리(Legacy · Company · AssignedPlans · TempUpload 다시 씀) · 시험 test_549 · test_546 stub 일반화

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:26:27 +09:00

248 lines
8.5 KiB
TypeScript

/* =============================================================================
* B01_Dashboard_UI_View_People.ts
* 세부 페이지 「사람」(549-1) — 탭 셋: 팀원 · 가입 요청(수 배지) · 초대.
* 회사 관리자 = 자기 회사 · 시스템 관리자 = 머리에서 고른 회사
* 팀원 줄 ⋯ = 수정 · 역할 변경 · 회사에서 빼기(빨강) — 확인은 그 모달이 맡음
* 가입 요청 줄 = [승인] [거절] · 초대 = 이메일로 안내 메일 · 소속 없는 가입자는 [팀원 추가]
* ========================================================================== */
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import {
fetchAllUsers,
fetchCompanyJoinRequests,
fetchCompanyMembers,
inviteMember,
type DashboardUser,
type JoinRequest,
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { formatDate, L } from "./B01_Dashboard_UI_Common";
import { onB01_JoinRequest_Process_Click } from "./B01_Dashboard_UI_Company";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openAddMemberModal,
openChangeRoleModal,
openDeleteUserModal,
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
dataTable,
emptyState,
kitIcon,
pageFrame,
primaryButton,
rowMenu,
tabBar,
type Cell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { pendingCount, roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
type TabKey = "members" | "requests" | "invite";
/** 승인 · 거절 뒤 페이지가 다시 그려져도 보던 탭을 유지 */
let lastTab: TabKey = "members";
const MEMBER_COLUMNS: Column[] = [
{ label: "이름" },
{ label: "이메일" },
{ label: "직급 · 부서" },
{ label: "역할", width: "120px" },
{ label: "상태", width: "90px" },
{ label: "", width: "44px" },
];
const REQUEST_COLUMNS: Column[] = [
{ label: "요청자" },
{ label: "이메일" },
{ label: "요청일", width: "110px" },
{ label: "상태", width: "90px" },
{ label: "", width: "150px", align: "end" },
];
function person(name: string): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-pg-person";
const avatar = document.createElement("span");
avatar.className = "b01-pg-avatar";
avatar.textContent = (name.trim() || "?").slice(0, 1);
const text = document.createElement("span");
text.className = "b01-pg-name__main";
text.textContent = name || "-";
wrap.append(avatar, text);
return wrap;
}
function memberRow(m: Member, ctx: ViewCtx): Cell[] {
const me = ctx.me;
const status = statusBadgeOf(m.status);
const items = [{ label: L("Common_Btn_Edit"), onClick: () => openEditUserModal(me, m) }];
const rows = [...items];
if (canChangeRole(me, m)) {
rows.push({ label: L("B01_Dashboard_ChangeRole"), onClick: () => openChangeRoleModal(m) });
}
const system = ctx.role === "SYSTEM_ADMIN";
const removable = canDeleteUser(me, m) && (!system || m.id !== me.id);
return [
person(m.name),
m.email,
[m.position, m.department].filter(Boolean).join(" · ") || "-",
badge(roleText(m.role), m.role === "ADMIN" || m.role === "SYSTEM_ADMIN" ? "accent" : "neutral"),
badge(status.text, status.tone),
rowMenu([
...rows,
...(removable
? [
{
label: L(system ? "B01_Dashboard_DeleteUser" : "B01_Dashboard_RemoveMember"),
danger: true,
ownConfirm: true,
onClick: () => openDeleteUserModal(m),
},
]
: []),
]),
];
}
function requestRow(r: JoinRequest, system: boolean): Cell[] {
const status = statusBadgeOf(r.status);
const waiting = !r.status || r.status.toUpperCase() === "PENDING";
const actions = document.createElement("span");
actions.className = "b01-pg-inline";
if (waiting) {
actions.append(
createButton({
label: L("B01_Dashboard_Approve"),
size: "sm",
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "APPROVE", system),
}),
createButton({
label: L("B01_Dashboard_Reject"),
size: "sm",
variant: "danger",
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "REJECT", system),
}),
);
}
return [
person(r.user_name ?? r.user_email),
r.user_email,
formatDate(r.requested_at),
badge(status.text, status.tone),
actions,
];
}
/** 초대 탭 — 아직 가입 전인 사람에게 안내 메일 · 이미 가입한 사람은 [팀원 추가] */
function invitePanel(companyId: number | null | undefined, onAdd: () => void): HTMLElement {
const box = document.createElement("div");
box.className = "b01-pg-card b01-pg-invite";
const email = createInputField({
label: "초대할 이메일",
type: "email",
placeholder: "name@company.com",
});
const send = createButton({
label: "초대 메일 보내기",
iconStart: kitIcon("mail"),
onClick: async () => {
const value = email.input.value.trim();
if (!value.includes("@")) {
email.setError("메일을 보낼 이메일 주소를 입력");
return;
}
email.setError();
try {
await inviteMember(value, null, companyId);
email.input.value = "";
showToast("안내 메일을 보냈음", "success");
} catch (error) {
showToast(
error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"),
"error",
);
}
},
});
const form = document.createElement("div");
form.className = "b01-pg-invite__form";
form.append(email.root, send);
const note = document.createElement("p");
note.className = "b01-pg-note";
note.textContent = "아직 가입하지 않은 사람에게 가입 안내 메일만 보냄 · 계정은 본인이 만듦";
const already = document.createElement("p");
already.className = "b01-pg-note";
already.append("이미 가입했고 소속이 없는 사람은 ");
const link = document.createElement("button");
link.type = "button";
link.className = "b01-pg-link";
link.textContent = "팀원 추가";
link.addEventListener("click", onAdd);
already.append(link, " 에서 바로 등록");
box.append(form, note, already);
return box;
}
async function loadPeople(ctx: ViewCtx): Promise<{ members: Member[]; requests: JoinRequest[] }> {
const system = ctx.role === "SYSTEM_ADMIN";
const [members, requests] = await Promise.all([
(system ? fetchAllUsers(ctx.companyId) : fetchCompanyMembers()).catch(() => [] as Member[]),
fetchCompanyJoinRequests(system ? ctx.companyId : undefined).catch(() => []),
]);
return { members: members as DashboardUser[], requests };
}
export async function mountPeople(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const system = ctx.role === "SYSTEM_ADMIN";
const companyId = system ? ctx.companyId : undefined;
const page = pageFrame(host, "사람", "회사 팀원 · 가입 요청 · 초대를 한곳에서 관리");
const addMember = (): void => openAddMemberModal(undefined, companyId);
page.setAction(primaryButton("팀원 추가", addMember));
const { members, requests } = await loadPeople(ctx);
if (!host.isConnected) return;
const content = document.createElement("div");
const draw = (key: TabKey): void => {
if (key === "members") {
content.replaceChildren(
members.length
? dataTable(
MEMBER_COLUMNS,
members.map((m) => memberRow(m, ctx)),
)
: emptyState({
icon: "users",
text: "등록된 팀원이 없음",
action: primaryButton("팀원 추가", addMember),
onGuide: () => ctx.go("guide"),
}),
);
} else if (key === "requests") {
content.replaceChildren(
requests.length
? dataTable(
REQUEST_COLUMNS,
requests.map((r) => requestRow(r, system)),
)
: emptyState({ icon: "users", text: "대기 중인 가입 요청이 없음" }),
);
} else {
content.replaceChildren(invitePanel(companyId, addMember));
}
};
const tabs = tabBar(
[
{ key: "members", label: "팀원" },
{ key: "requests", label: "가입 요청" },
{ key: "invite", label: "초대" },
],
lastTab,
(key) => draw((lastTab = key as TabKey)),
);
tabs.setBadge("requests", pendingCount(requests));
page.body.append(tabs.root, content);
draw(lastTab);
}