Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

# Conflicts:
#	B01_Dashboard/B01_Dashboard_UI_Views.ts
#	resources/tester/test_546_b01_shell.py
This commit is contained in:
2026-10-26 18:28:47 +09:00
14 changed files with 1338 additions and 259 deletions
+8
View File
@@ -495,6 +495,14 @@ export async function fetchAuditLogs(): Promise<AuditLog[]> {
return data.items;
}
/** 시스템 로그 한 쪽 — 최신순 · total = 전체 줄 수 */
export async function fetchAuditLogPage(
page: number,
limit = 100,
): Promise<{ items: AuditLog[]; total: number }> {
return request(`/dashboard/admin/audit-logs?page=${page}&limit=${limit}`);
}
export async function fetchSystemResources(days = 30): Promise<ResourceData> {
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
}
+2 -35
View File
@@ -27,7 +27,7 @@ import {
type ProjectItem,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import { userTable } from "./B01_Dashboard_UI_Admin";
import {
assignedPlanTable,
fetchAssignedPlans,
@@ -41,11 +41,9 @@ import {
joinRequestTable,
memberTable,
} from "./B01_Dashboard_UI_Company";
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
import { openAddMemberModal } from "./B01_Dashboard_UI_Modals";
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
export interface DashboardState {
@@ -215,37 +213,6 @@ const SECTIONS: Record<string, (s: DashboardState) => HTMLElement[]> = {
true,
),
],
sysOverview: (s) => [
section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true),
],
sysCompanies: (s) => [
section(
L("B01_Dashboard_Companies"),
companyTable(s.allCompanies, s.user),
true,
addButton(() => openCreateCompanyModal()),
),
],
sysUsers: (s) => [
section(
L("B01_Dashboard_Users"),
userTable(s.allUsers, s.user),
true,
addButton(() => openAddMemberModal(undefined, s.adminCompanyId)),
),
],
sysDefaults: (s) => [
section(
L("B01_Dashboard_SystemSettings"),
buildSystemSettingsPanel(s.user, s.adminCompanyId),
true,
),
],
sysLogs: (s) => [section(L("B01_Dashboard_AuditLogs"), auditLogTable(s.auditLogs), true)],
me: (s) => [
section(L("B01_Dashboard_Profile"), buildProfileForm(s.user), true),
section(L("B01_Account_Section_Security"), buildSecurityForm(), true),
],
};
export type LegacyKey = keyof typeof SECTIONS;
+89 -67
View File
@@ -1,5 +1,11 @@
/* =============================================================================
* B01_Dashboard_UI_Profile.ts
* 내 정보 카드(549-2) — 기본정보(이름 · 직급 · 부서 · 전화 · 이메일 · 서명) · 비밀번호 변경.
* 모양 = 설정 줄(제목 · 설명 · 오른쪽 조작) · 저장 길은 옛 폼과 같음(`updateUserProfile` · `changePassword`).
* ========================================================================== */
import { isBlank } from "@util/common_util_validate";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { createButton } from "@ui/ui_template_elements";
import {
changePassword,
fetchCompanyAssets,
@@ -8,26 +14,43 @@ import {
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
import { L, runRequest } from "./B01_Dashboard_UI_Common";
import { settingCard, settingRow, valueText } from "./B01_Dashboard_UI_PageKit";
import { el } from "./B01_Dashboard_UI_View_Parts";
const PASSWORD_MIN_LENGTH = 8;
export function buildProfileForm(user: DashboardUser): HTMLElement {
// 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시).
const fields = buildUserFields(user, { self: true });
const grid = fields.grid;
// 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때
// 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시).
const signatureSlot = document.createElement("div");
function input(value: string, type = "text", label = ""): HTMLInputElement {
const box = el("input", "b01-sys-input");
box.type = type;
box.value = value;
if (label) box.ariaLabel = label;
return box;
}
/** 입력 줄 아래에 오류 글 — 비우면 지움 */
function fieldError(row: HTMLElement, message?: string): void {
row.querySelector(".b01-sys-error")?.remove();
if (message) row.querySelector(".b01-pg-row__text")?.append(el("span", "b01-sys-error", message));
}
export function buildProfileCard(user: DashboardUser): HTMLElement {
const name = input(user.name, "text", "이름");
const position = input(user.position ?? "", "text", "직급");
const department = input(user.department ?? "", "text", "부서");
const phone = input(user.phone ?? "", "tel", "전화");
const nameRow = settingRow("이름", "도면 표제란 · 보고서에 들어갑니다.", name);
// 본인 서명 — 도면 표제란이 이 사람 자리를 채울 때 그대로 실림(회사 소속일 때만)
const signature = el("div", "b01-sys-assetslot");
if (user.company_id) {
void fetchCompanyAssets(user.company_id).then((assets) => {
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id);
signatureSlot.append(
const owned = assets.find((a) => a.kind === "SIGNATURE" && a.user_id === user.id);
signature.append(
createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, {
owner: { id: user.id, name: user.name },
onChange: async (assetId) => {
if (assetId === null) return;
const picked = assets.find((asset) => asset.id === assetId);
const picked = assets.find((a) => a.id === assetId);
if (picked)
await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id });
},
@@ -38,73 +61,72 @@ export function buildProfileForm(user: DashboardUser): HTMLElement {
const save = createButton({
label: L("B01_Dashboard_SaveProfile"),
onClick: async function onB01_Profile_Save_Click() {
if (!fields.validate()) return;
await runRequest(() => updateUserProfile(fields.values()));
fieldError(nameRow);
if (!name.value.trim()) return fieldError(nameRow, L("Common_Msg_RequiredField"));
await runRequest(() =>
updateUserProfile({
name: name.value.trim(),
position: position.value.trim() || null,
department: department.value.trim() || null,
phone: phone.value.trim() || null,
}),
);
},
});
const wrap = document.createElement("div");
wrap.append(grid, signatureSlot, save);
return wrap;
const rows = [
nameRow,
settingRow("직급", "", position),
settingRow("부서", "", department),
settingRow("전화", "", phone),
settingRow("이메일", "로그인에 쓰는 계정이라 바꿀 수 없습니다.", valueText(user.email)),
];
if (user.company_id)
rows.push(settingRow("서명", "보고서 서명란 · 도면 결재란에 들어갑니다.", signature));
rows.push(settingRow("", "", save));
return settingCard(rows);
}
export function buildSecurityForm(): HTMLElement {
const currentPassword = createInputField({
label: L("B01_Account_Field_CurrentPw"),
type: "password",
required: true,
});
const newPassword = createInputField({
label: L("B01_Account_Field_NewPw"),
type: "password",
required: true,
});
const confirmPassword = createInputField({
label: L("B01_Account_Field_ConfirmPw"),
type: "password",
required: true,
});
const grid = document.createElement("div");
grid.className = "b01-dashboard__form-grid";
grid.append(currentPassword.root, newPassword.root, confirmPassword.root);
/** 비밀번호 — [변경] 을 누르면 입력 줄이 펼쳐짐 */
export function buildPasswordCard(): HTMLElement {
const current = input("", "password", "현재 비밀번호");
const next = input("", "password", "새 비밀번호");
const confirm = input("", "password", "새 비밀번호 확인");
const rows = [
settingRow("현재 비밀번호", "", current),
settingRow("새 비밀번호", `${PASSWORD_MIN_LENGTH} 자 이상`, next),
settingRow("새 비밀번호 확인", "", confirm),
];
const form = el("div");
form.hidden = true;
const save = createButton({
label: L("B01_Account_Save_Password"),
variant: "ghost",
onClick: async function onB01_Password_Save_Click() {
currentPassword.setError();
newPassword.setError();
confirmPassword.setError();
const currentValue = currentPassword.input.value;
const nextValue = newPassword.input.value;
const confirmValue = confirmPassword.input.value;
if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) {
currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined);
newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined);
confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined);
return;
}
if (nextValue.length < PASSWORD_MIN_LENGTH) {
newPassword.setError(L("B01_Account_Error_PwLength"));
return;
}
if (nextValue !== confirmValue) {
confirmPassword.setError(L("B01_Account_Error_PwMismatch"));
return;
}
for (const r of rows) fieldError(r);
const required = L("Common_Msg_RequiredField");
if (isBlank(current.value)) return fieldError(rows[0], required);
if (isBlank(next.value)) return fieldError(rows[1], required);
if (isBlank(confirm.value)) return fieldError(rows[2], required);
if (next.value.length < PASSWORD_MIN_LENGTH)
return fieldError(rows[1], L("B01_Account_Error_PwLength"));
if (next.value !== confirm.value)
return fieldError(rows[2], L("B01_Account_Error_PwMismatch"));
await runRequest(() =>
changePassword({
current_password: currentValue,
new_password: nextValue,
new_password_confirm: confirmValue,
current_password: current.value,
new_password: next.value,
new_password_confirm: confirm.value,
logout_all: false,
}),
);
currentPassword.input.value = "";
newPassword.input.value = "";
confirmPassword.input.value = "";
current.value = next.value = confirm.value = "";
form.hidden = true;
},
});
const wrap = document.createElement("div");
wrap.append(grid, save);
return wrap;
form.append(...rows, settingRow("", "", save));
const toggle = createButton({
label: "변경",
variant: "ghost",
onClick: () => (form.hidden = !form.hidden),
});
return settingCard([settingRow("비밀번호", "주기적으로 바꾸면 안전합니다.", toggle), form]);
}
-151
View File
@@ -1,151 +0,0 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createLineChart } from "@ui/ui_template_elements";
import type { ResourceData } from "./B01_Dashboard_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function percent(value?: number | null): string {
return value == null ? "-" : `${Math.round(value)}%`;
}
export function buildResourcePanel(resources: ResourceData | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__resource-panel";
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources));
return wrap;
}
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
const values = resources?.current;
const grid = document.createElement("div");
grid.className = "b01-dashboard__metric-grid";
const items = [
[L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)],
[L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)],
[L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)],
[L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0],
[L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0],
];
for (const [label, value] of items) {
const box = document.createElement("div");
box.className = "b01-dashboard__metric";
const labelEl = document.createElement("span");
labelEl.className = "b01-dashboard__metric-label";
labelEl.textContent = String(label);
const valueEl = document.createElement("span");
valueEl.className = "b01-dashboard__metric-value";
valueEl.textContent = String(value);
box.append(labelEl, valueEl);
grid.append(box);
}
return grid;
}
function buildResourceChart(resources: ResourceData | null): HTMLElement {
const box = document.createElement("div");
box.className = "b01-dashboard__chart";
const caption = document.createElement("p");
caption.className = "b01-dashboard__chart-caption";
caption.textContent = L("B01_Dashboard_Resources_ChartCaption");
box.append(caption);
const chartWrap = document.createElement("div");
chartWrap.className = "b01-dashboard__chart-content";
box.append(chartWrap);
const history = resources?.history ?? [];
const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] =
[];
const now = new Date();
const baseDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours() >= 12 ? 24 : 12,
0,
0,
0,
);
// 7일을 1시간 버킷으로 분할. 버킷이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐
// 점 하나(선 없음)가 되므로, 가동 시간대가 연속 버킷으로 이어지도록 1시간 단위 사용.
const HOURS = 7 * 24;
for (let i = 0; i <= HOURS; i++) {
const time = new Date(baseDate.getTime() - (HOURS - i) * 60 * 60 * 1000);
points.push({ timestamp: time, cpu: null, mem: null, disk: null });
}
history.forEach((h) => {
if (!h.timestamp) return;
const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z";
const hTime = new Date(tsStr);
let closestIdx = -1;
let minDiff = Infinity;
points.forEach((p, idx) => {
const diff = Math.abs(p.timestamp.getTime() - hTime.getTime());
if (diff < minDiff && diff <= 30 * 60 * 1000) {
minDiff = diff;
closestIdx = idx;
}
});
if (closestIdx !== -1) {
const p = points[closestIdx];
p.cpu = h.cpu_usage_percent;
p.mem = h.memory_usage_percent;
p.disk = h.disk_usage_percent;
}
});
const xLabels = points.map((p) => {
const hours = p.timestamp.getHours();
return hours === 0 ? `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}` : "";
});
let resizeTimeout: number | null = null;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = Math.floor(entry.contentRect.width);
if (width <= 0) continue;
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
resizeTimeout = window.requestAnimationFrame(() => {
chartWrap.innerHTML = "";
const chart = createLineChart({
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
xLabels,
width: width,
height: 140,
series: [
{
name: L("B01_Dashboard_Metric_Cpu"),
colorIndex: 0,
values: points.map((p) => p.cpu),
},
{
name: L("B01_Dashboard_Metric_Memory"),
colorIndex: 1,
values: points.map((p) => p.mem),
},
{
name: L("B01_Dashboard_Metric_Disk"),
colorIndex: 2,
values: points.map((p) => p.disk),
},
],
});
chartWrap.append(chart);
});
}
});
observer.observe(box);
return box;
}
@@ -422,3 +422,196 @@
font-size: 12.5px;
color: var(--b01-muted);
}
/* ── 시스템 관리 · 내 정보 (549-2) ────────────────────────────────────────── */
.b01-shell {
--b01-warn: #d9822b;
--b01-s1: var(--b01-accent);
--b01-s2: #3b82c4;
--b01-s3: var(--b01-warn);
}
.b01-sys-loading {
margin: 0;
color: var(--b01-muted);
}
.b01-sys-tool {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.b01-sys-input {
min-height: 32px;
padding: 0 8px;
border: 1px solid var(--b01-line);
border-radius: 8px;
background: var(--b01-card);
color: var(--b01-text);
font: inherit;
font-size: 13px;
}
.b01-sys-input:focus-visible {
outline: 2px solid var(--b01-accent);
outline-offset: 0;
}
.b01-pg-row__side .b01-sys-input {
width: 240px;
}
.b01-sys-count {
margin-left: auto;
color: var(--b01-muted);
font-size: 12px;
}
.b01-sys-group__title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--b01-text);
}
.b01-pg__body > .b01-sys-group + .b01-sys-group {
margin-top: 8px;
}
a.b01-pg-name__main {
color: var(--b01-text);
text-decoration: none;
}
a.b01-pg-name__main:hover {
color: var(--b01-accent);
text-decoration: underline;
}
.b01-sys-error {
color: var(--b01-alert);
font-size: 12px;
}
.b01-sys-assetslot .ui-field__label {
display: none;
}
.b01-sys-seg {
display: inline-flex;
border: 1px solid var(--b01-line);
border-radius: 8px;
overflow: hidden;
}
.b01-sys-seg button {
min-height: 32px;
padding: 0 12px;
border: 0;
background: var(--b01-card);
color: var(--b01-muted);
font: inherit;
cursor: pointer;
}
.b01-sys-seg button[aria-pressed="true"] {
background: var(--b01-accent-soft);
color: var(--b01-accent);
font-weight: 600;
}
/* 개요 — 숫자 카드 · 게이지 · 7 일 그래프 */
.b01-sys-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.b01-sys-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
}
.b01-sys-stat__label {
color: var(--b01-muted);
font-size: 12px;
}
.b01-sys-stat__value {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.b01-sys-gauge .b01-sys-stat__value {
font-size: 22px;
}
.b01-sys-gauge {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
}
.b01-sys-gauge__top {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.b01-sys-gauge__bar {
height: 8px;
border-radius: 4px;
background: var(--b01-hover);
overflow: hidden;
}
.b01-sys-gauge__fill {
height: 100%;
border-radius: 4px;
background: var(--b01-accent);
}
.b01-sys-gauge.is-warn .b01-sys-gauge__fill {
background: var(--b01-warn);
}
.b01-sys-gauge.is-bad .b01-sys-gauge__fill {
background: var(--b01-alert);
}
.b01-sys-gauge.is-warn .b01-sys-stat__value {
color: var(--b01-warn);
}
.b01-sys-gauge.is-bad .b01-sys-stat__value {
color: var(--b01-alert);
}
.b01-sys-chart {
padding: 16px;
}
.b01-sys-chart__legend {
display: flex;
gap: 16px;
margin-bottom: 8px;
color: var(--b01-muted);
font-size: 12px;
}
.b01-sys-chart__legend i {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 4px;
border-radius: 2px;
}
.b01-sys-chart svg {
display: block;
width: 100%;
height: auto;
}
.b01-sys-chart text {
fill: var(--b01-muted);
font-size: 10px;
}
.b01-sys-chart line {
stroke: var(--b01-line);
}
/* 공용 단추 눌림 색 — 기본이 보라(royal-amethyst · mist-violet)라 셸 강조 색으로 */
.b01-pg .ui-btn--filled:hover:not(:disabled) {
background-color: var(--b01-accent);
filter: brightness(0.92);
}
.b01-pg .ui-btn--ghost:hover:not(:disabled) {
background-color: var(--b01-accent-soft);
}
.b01-sys-back {
align-self: flex-start;
padding: 0;
border: 0;
background: none;
color: var(--b01-muted);
font: inherit;
cursor: pointer;
}
.b01-sys-back:hover {
color: var(--b01-text);
}
+72
View File
@@ -0,0 +1,72 @@
/* =============================================================================
* B01_Dashboard_UI_View_Me.ts
* 내 정보(549-2) — Linear 설정 꼴: 묶음 제목 + 둥근 상자 안 「제목 · 설명 · 오른쪽 조작」 줄.
* 기본정보 · 보안(비밀번호) · 화면(밝음 · 어두움) · 내 설계 기본값(구조물 조합 개인 층).
* ========================================================================== */
import { THEME_STORAGE_KEY } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import { buildPasswordCard, buildProfileCard } from "./B01_Dashboard_UI_Profile";
import { el, group } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
type Theme = "light" | "dark";
const currentTheme = (): Theme =>
document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
function setTheme(theme: Theme): void {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
}
/** 밝음 · 어두움 두 칸 — 고르면 바로 바뀌고 이 브라우저에 기억 */
function themeSwitch(): HTMLElement {
const seg = el("div", "b01-sys-seg");
const options: [Theme, string][] = [
["light", "밝음"],
["dark", "어두움"],
];
const sync = (): void => {
for (const b of seg.querySelectorAll("button"))
b.setAttribute("aria-pressed", String(b.dataset.theme === currentTheme()));
};
for (const [theme, label] of options) {
const b = el("button", "", label);
b.type = "button";
b.dataset.theme = theme;
b.addEventListener("click", () => {
setTheme(theme);
sync();
});
seg.append(b);
}
sync();
return seg;
}
export function mountMe(host: HTMLElement, ctx: ViewCtx): void {
const page = pageFrame(host, "내 정보", "내 계정과 화면 설정을 관리합니다.");
const defaults = settingLinks(ctx.me, ctx.companyId)
.filter((link) => link.key === "구조물조합")
.map((link) =>
settingRow(
`${link.title} (내 것)`,
link.desc,
createButton({
label: "열기",
variant: "ghost",
onClick: () => navigateTo(link.route, link.query),
}),
),
);
page.body.append(
group("기본정보", buildProfileCard(ctx.me)),
group("보안", buildPasswordCard()),
group("화면", settingCard([settingRow("테마", "밝은 화면 · 어두운 화면", themeSwitch())])),
...(defaults.length ? [group("내 설계 기본값", settingCard(defaults))] : []),
);
}
@@ -0,0 +1,82 @@
/* =============================================================================
* B01_Dashboard_UI_View_Parts.ts
* 시스템 관리 · 내 정보 페이지(549-2) 곁부품 — 불러오기 틀 · 거르기 줄 · 묶음 제목.
* 공용 조각(`B01_Dashboard_UI_PageKit.ts` · sub4)에 없는 것만 · 모양은 `Style_Pages.css` 아래 「시스템 관리」 쪽.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { emptyState } from "./B01_Dashboard_UI_PageKit";
export const el = <K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
text?: string,
): HTMLElementTagNameMap[K] => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
/**
* 자료를 읽는 동안 안내 → 다 읽으면 build 가 stage 에 그린 것으로 바꿈.
* 실패하면 까닭 + [다시 시도] · 그 사이 떠났으면(host 떨어짐) 안 그림.
*/
export function loadPage(host: HTMLElement, build: (stage: HTMLElement) => Promise<void>): void {
const run = (): void => {
host.replaceChildren(el("p", "b01-sys-loading", "불러오는 중…"));
const stage = el("div", "b01-sys-stage");
build(stage).then(
() => host.isConnected && host.replaceChildren(...stage.childNodes),
(error: unknown) => {
if (!host.isConnected) return;
const retry = createButton({ label: "다시 시도", variant: "ghost", onClick: run });
const page = el("div", "b01-pg");
page.append(
emptyState({
icon: "bell",
text: error instanceof Error ? error.message : "불러오지 못했습니다.",
action: retry,
}),
);
host.replaceChildren(page);
},
);
};
run();
}
/** 거르기 줄 */
export function toolbar(...items: HTMLElement[]): HTMLElement {
const bar = el("div", "b01-sys-tool");
bar.append(...items);
return bar;
}
export function searchBox(placeholder: string, onInput: (value: string) => void): HTMLInputElement {
const input = el("input", "b01-sys-input");
input.type = "search";
input.placeholder = placeholder;
input.ariaLabel = placeholder;
input.addEventListener("input", () => onInput(input.value.trim().toLowerCase()));
return input;
}
export function selectBox(
label: string,
options: [value: string, text: string][],
onChange: (value: string) => void,
): HTMLSelectElement {
const select = el("select", "b01-sys-input");
select.ariaLabel = label;
for (const [value, text] of options) select.append(new Option(text, value));
select.addEventListener("change", () => onChange(select.value));
return select;
}
/** 묶음 — 제목 + 아래 내용(설정 카드 · 표) */
export function group(title: string, content: Node): HTMLElement {
const box = el("section", "b01-sys-group");
box.append(el("h3", "b01-sys-group__title", title), content);
return box;
}
@@ -0,0 +1,228 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysCompanies.ts
* 시스템 관리 › 회사(549-2) — 회사 목록(찾기 · 줄 끝 ⋯) → 회사 상세(정보 · 사람 · 프로젝트 탭) · [+ 회사].
* 상세 = 주소 `&company=<id>`(뒤로 가기 됨) · 수정 · 로고 · 새 회사 = 지금 창(Modals) 그대로.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { currentQuery } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
fetchCompanyProjects,
setCompanyLogo,
type CompanyInfo,
type ProjectItem,
} from "./B01_Dashboard_Api_Fetch";
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
import { formatDate } from "./B01_Dashboard_UI_Common";
import { filterCompanies } from "./B01_Dashboard_UI_CompanyPick";
import { openCreateCompanyModal, openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
import { viewHref } from "./B01_Dashboard_UI_Nav";
import {
badge,
dataTable,
emptyState,
nameCell,
pageFrame,
primaryButton,
rowMenu,
settingCard,
settingRow,
tabBar,
valueText,
} from "./B01_Dashboard_UI_PageKit";
import { el, loadPage, searchBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
export const KIND_KO: Record<string, string> = {
ROAD: "임도설계",
PLAN: "예정노선",
STABILITY: "안정성평가",
EROSION: "사방설계",
};
const statusTag = (s?: string | null): HTMLElement =>
badge(s || "-", s === "활동중" ? "success" : "neutral");
const logoLabel = (c: CompanyInfo): string => (c.logo_asset_id ? "로고 변경…" : "로고 지정…");
function pickLogo(ctx: ViewCtx, company: CompanyInfo, after?: () => void): void {
void openAssetPicker("LOGO", company.id, ctx.me, company.logo_asset_id ?? null, async (id) => {
await setCompanyLogo(id, company.id);
company.logo_asset_id = id;
after?.();
});
}
/** 이름 칸 — 이름은 상세로 가는 링크(뒤로 가기 · 새 탭 됨) + 아래 작은 주소 */
function linkedName(c: CompanyInfo): HTMLElement {
const link = el("a", "b01-pg-name__main", c.name);
link.href = viewHref("sys-companies", { company: String(c.id) });
const cell = nameCell(c.name, c.business_address);
cell.firstElementChild!.replaceWith(link);
return cell;
}
const LIST_COLUMNS = [
{ label: "회사" },
{ label: "사업자등록번호" },
{ label: "대표자" },
{ label: "상태" },
{ label: "사용자", align: "end" as const },
{ label: "프로젝트", align: "end" as const },
{ label: "" },
];
function companyList(stage: HTMLElement, ctx: ViewCtx, companies: CompanyInfo[]): void {
const page = pageFrame(
stage,
"회사",
"등록된 회사를 보고 정보 · 로고를 고칩니다. 이름을 누르면 상세입니다.",
);
page.setAction(primaryButton("회사", () => openCreateCompanyModal()));
const body = el("div");
const draw = (query: string): void => {
const shown = filterCompanies(companies, query);
if (!shown.length) {
body.replaceChildren(
companies.length
? emptyState({ icon: "search", text: "찾는 회사가 없습니다." })
: emptyState({
icon: "building",
text: "등록된 회사가 없습니다.",
action: primaryButton("회사", () => openCreateCompanyModal()),
}),
);
return;
}
body.replaceChildren(
dataTable(
LIST_COLUMNS,
shown.map((c) => [
linkedName(c),
c.business_registration_number ?? "-",
c.business_owner ?? "-",
statusTag(c.business_status),
String(c.user_count ?? 0),
String(c.project_count ?? 0),
rowMenu([
{
label: "상세 보기",
onClick: () => ctx.go("sys-companies", { company: String(c.id) }),
},
{ label: "정보 수정", onClick: () => openEditCompanyModal(c) },
{ label: logoLabel(c), onClick: () => pickLogo(ctx, c) },
]),
]),
),
);
};
draw("");
page.body.append(toolbar(searchBox("회사 이름 · 사업자번호 찾기", draw)), body);
}
const projectTable = (projects: ProjectItem[]): HTMLElement =>
projects.length
? dataTable(
[
{ label: "프로젝트" },
{ label: "종류" },
{ label: "지역" },
{ label: "단계" },
{ label: "담당" },
{ label: "수정일" },
],
projects.map((p) => [
p.name,
KIND_KO[p.project_kind ?? "ROAD"] ?? "-",
p.region ?? "-",
`${p.workflow_stage} 단계`,
p.owner_name ?? "-",
formatDate(p.updated_at),
]),
)
: emptyState({ icon: "folder", text: "이 회사의 프로젝트가 없습니다." });
async function companyDetail(
stage: HTMLElement,
ctx: ViewCtx,
company: CompanyInfo,
): Promise<void> {
const [users, projects] = await Promise.all([
fetchAllUsers(company.id).catch(() => []),
fetchCompanyProjects(company.id).catch(() => []),
]);
const page = pageFrame(stage, company.name, "회사 상세");
page.setAction(
createButton({ label: "정보 수정", onClick: () => openEditCompanyModal(company) }),
);
const back = el("button", "b01-sys-back", "‹ 회사 목록");
back.type = "button";
back.addEventListener("click", () => ctx.go("sys-companies"));
const logoBtn = createButton({
label: logoLabel(company),
variant: "ghost",
onClick: () => pickLogo(ctx, company, () => (logoBtn.textContent = logoLabel(company))),
});
const info = settingCard([
settingRow("회사 이름", "", valueText(company.name)),
settingRow("사업자등록번호", "", valueText(company.business_registration_number)),
settingRow("대표자", "", valueText(company.business_owner)),
settingRow("주소", "", valueText(company.business_address)),
settingRow("상태", "", statusTag(company.business_status)),
settingRow("로고", "도면 표제란에 들어갑니다.", logoBtn),
]);
const people = users.length
? dataTable(
[
{ label: "이름" },
{ label: "직급" },
{ label: "부서" },
{ label: "역할" },
{ label: "상태" },
{ label: "" },
],
users.map((u) => {
const st = statusBadgeOf(u.status);
return [
nameCell(u.name, u.email),
u.position ?? "-",
u.department ?? "-",
roleText(u.role),
badge(st.text, st.tone),
rowMenu([
{
label: "사용자 페이지에서 보기",
onClick: () => ctx.go("sys-users", { company: String(company.id), q: u.email }),
},
]),
];
}),
)
: emptyState({ icon: "users", text: "이 회사에 소속된 사용자가 없습니다." });
const panes: Record<string, HTMLElement> = { info, people, projects: projectTable(projects) };
const pane = el("div");
pane.append(info);
const tabs = tabBar(
[
{ key: "info", label: "정보" },
{ key: "people", label: `사람 ${users.length}` },
{ key: "projects", label: `프로젝트 ${projects.length}` },
],
"info",
(key) => pane.replaceChildren(panes[key]),
);
page.body.append(back, tabs.root, pane);
}
export function mountSysCompanies(host: HTMLElement, ctx: ViewCtx): void {
const id = Number(currentQuery().get("company"));
loadPage(host, async (stage) => {
const companies = await fetchAllCompanies();
const company = id ? companies.find((c) => c.id === id) : undefined;
if (company) await companyDetail(stage, ctx, company);
else companyList(stage, ctx, companies);
});
}
@@ -0,0 +1,36 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysDefaults.ts
* 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리.
* 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { settingLinks, type SettingLink } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import { group } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */
const GROUP_OF: Record<string, string> = {
m01: "마스터 데이터",
구조물조합: "구조물 · 도면",
옵션: "구조물 · 도면",
도면: "구조물 · 도면",
calcSites: "계산",
};
const open = (link: SettingLink): void =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query);
export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void {
const groups = new Map<string, HTMLElement[]>();
for (const link of settingLinks(ctx.me, ctx.companyId)) {
const title = GROUP_OF[link.key] ?? "그 밖";
const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) });
groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]);
}
const page = pageFrame(host, "설계 기본값", "새 프로젝트가 처음 받는 마스터 값을 관리합니다.");
page.body.append(...[...groups].map(([title, rows]) => group(title, settingCard(rows))));
}
@@ -0,0 +1,166 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysLogs.ts
* 시스템 관리 › 시스템 로그(549-2) — 동작 · 대상 · 접속 주소 · 일시 표 + 거르기(동작 · 기간 · 찾기).
* 서버는 최신순 쪽(100 줄)만 줌 → 받은 줄 안에서 거르고 [이전 기록 더 보기] 로 다음 쪽을 이어 붙임.
* ========================================================================== */
import { fetchAuditLogPage, type AuditLog } from "./B01_Dashboard_Api_Fetch";
import { formatDate, formatTime } from "./B01_Dashboard_UI_Common";
import { createButton } from "@ui/ui_template_elements";
import { dataTable, emptyState, pageFrame } from "./B01_Dashboard_UI_PageKit";
import { el as h, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const PAGE = 100;
export const ACTION_KO: Record<string, string> = {
PROJECT_CREATE: "프로젝트 생성",
PROJECT_UPDATE: "프로젝트 수정",
PROJECT_DELETE: "프로젝트 삭제",
PROJECT_HARD_DELETE: "프로젝트 영구 삭제",
COMPANY_CREATE: "회사 생성",
CALC_SITE_CHANGE: "계산 자리 변경",
RATE_ANOMALY: "호출 이상",
M02_OTHER_LAYER_WRITE: "마스터 다른 층 수정",
};
const TARGET_KO: Record<string, string> = {
PROJECT: "프로젝트",
COMPANY: "회사",
USER: "사용자",
ASSET: "자산",
M02_PERSONAL: "개인 마스터",
CALC_SITE: "계산 자리",
};
export const actionLabel = (action: string): string => ACTION_KO[action] ?? action;
/** 무엇에 한 일인지 — 종류 + 번호(UUID 는 앞 8 자) */
export function targetText(log: AuditLog): string {
const kind = TARGET_KO[(log.resource_type ?? "").toUpperCase()] ?? log.resource_type ?? "";
const ref = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : "");
const short = ref.length > 12 ? `${ref.slice(0, 8)}…` : ref;
return kind ? `${kind} ${short}`.trim() : short || "-";
}
export interface LogFilter {
action: string;
from: string;
to: string;
q: string;
}
/** 동작 · 기간(날짜 YYYY-MM-DD 양끝 포함) · 찾기(이메일 · 대상 · 접속 주소) */
export function filterLogs(logs: AuditLog[], f: LogFilter): AuditLog[] {
return logs.filter((l) => {
const day = (l.timestamp ?? "").slice(0, 10);
if (f.action && l.action !== f.action) return false;
if (f.from && day < f.from) return false;
if (f.to && day > f.to) return false;
if (!f.q) return true;
return [l.email, targetText(l), l.ip_address, actionLabel(l.action)]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(f.q);
});
}
function dateInput(label: string, onChange: (v: string) => void): HTMLInputElement {
const input = h("input", "b01-sys-input");
input.type = "date";
input.ariaLabel = label;
input.title = label;
input.addEventListener("change", () => onChange(input.value));
return input;
}
const COLUMNS = [
{ label: "사용자" },
{ label: "동작" },
{ label: "대상" },
{ label: "접속 주소" },
{ label: "일시" },
];
export function mountSysLogs(host: HTMLElement, _ctx: ViewCtx): void {
loadPage(host, async (stage) => {
const first = await fetchAuditLogPage(1, PAGE);
let logs = first.items;
let page = 1;
const total = first.total;
const f: LogFilter = { action: "", from: "", to: "", q: "" };
const body = h("div");
const count = h("span", "b01-sys-count");
const more = createButton({
label: "이전 기록 더 보기",
variant: "ghost",
onClick: () => void loadMore(),
});
const actionBox = h("span");
const drawActions = (): void => {
const kinds = [...new Set(logs.map((l) => l.action))].sort();
const select = selectBox(
"동작",
[["", "모든 동작"], ...kinds.map((a): [string, string] => [a, actionLabel(a)])],
(v) => {
f.action = v;
draw();
},
);
select.value = f.action;
actionBox.replaceChildren(select);
};
const draw = (): void => {
const shown = filterLogs(logs, f);
count.textContent = `${shown.length} / ${logs.length} 줄 (전체 ${total} 줄)`;
more.hidden = logs.length >= total;
body.replaceChildren(
shown.length
? dataTable(
COLUMNS,
shown.map((l) => {
const action = h("span", "", actionLabel(l.action));
action.title = l.action;
return [
l.email ?? "-",
action,
targetText(l),
l.ip_address ?? "-",
`${formatDate(l.timestamp)} ${formatTime(l.timestamp)}`.trim(),
];
}),
)
: emptyState({
icon: "fileText",
text: logs.length ? "조건에 맞는 기록이 없습니다." : "기록이 없습니다.",
}),
);
};
async function loadMore(): Promise<void> {
page += 1;
logs = logs.concat((await fetchAuditLogPage(page, PAGE)).items);
drawActions();
draw();
}
drawActions();
draw();
const search = searchBox("사용자 · 대상 · 접속 주소 찾기", (v) => {
f.q = v;
draw();
});
const frame = pageFrame(
stage,
"시스템 로그",
"누가 무엇을 했는지 최신순으로 봅니다. 기록은 1 년 보관됩니다.",
);
const from = dateInput("시작일", (v) => {
f.from = v;
draw();
});
const to = dateInput("종료일", (v) => {
f.to = v;
draw();
});
frame.body.append(toolbar(search, actionBox, from, h("span", "", "~"), to, count), body, more);
});
}
@@ -0,0 +1,155 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysOverview.ts
* 시스템 관리 › 개요(549-2) — 숫자 카드(회사 · 사용자 · 활성 프로젝트 · 활성 사용자) +
* CPU · 메모리 · 디스크 게이지(넘으면 주황 · 빨강) + 7 일 그래프(인라인 SVG).
* 자료 = 리소스 API(7 일) · 회사 목록 · 전체 사용자 — 시스템 관리자만(서버도 막음).
* ========================================================================== */
import {
fetchAllCompanies,
fetchAllUsers,
fetchSystemResources,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { pageFrame } from "./B01_Dashboard_UI_PageKit";
import { el as h, loadPage } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const SVG_NS = "http://www.w3.org/2000/svg";
const DAY_MS = 86_400_000;
/** 게이지 색 바뀌는 선 — 이 % 이상 주황 · 빨강 */
export const GAUGE_WARN = 70;
export const GAUGE_BAD = 90;
export const gaugeTone = (percent: number | null | undefined): "ok" | "warn" | "bad" =>
percent == null || percent < GAUGE_WARN ? "ok" : percent < GAUGE_BAD ? "warn" : "bad";
function statCard(label: string, value: number | string): HTMLElement {
const card = h("div", "b01-pg-card b01-sys-stat");
card.append(
h("span", "b01-sys-stat__label", label),
h("span", "b01-sys-stat__value", String(value)),
);
return card;
}
function gauge(label: string, percent: number | null | undefined): HTMLElement {
const card = h("div", `b01-pg-card b01-sys-gauge is-${gaugeTone(percent)}`);
const top = h("div", "b01-sys-gauge__top");
top.append(
h("span", "b01-sys-stat__label", label),
h("span", "b01-sys-stat__value", percent == null ? "-" : `${Math.round(percent)}%`),
);
const bar = h("div", "b01-sys-gauge__bar");
bar.setAttribute("role", "progressbar");
bar.setAttribute("aria-valuenow", String(Math.round(percent ?? 0)));
const fill = h("div", "b01-sys-gauge__fill");
fill.style.width = `${Math.min(100, Math.max(0, percent ?? 0))}%`;
bar.append(fill);
card.append(top, bar);
return card;
}
const svg = (tag: string, attrs: Record<string, string | number>): SVGElement => {
const e = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
return e;
};
const SERIES = [
{ name: "CPU", key: "cpu_usage_percent", color: "var(--b01-s1)" },
{ name: "메모리", key: "memory_usage_percent", color: "var(--b01-s2)" },
{ name: "디스크", key: "disk_usage_percent", color: "var(--b01-s3)" },
] as const;
/** 7 일 그래프 — 가로 = 시각 · 세로 0~100 % · 세 시간 넘게 비면 선을 끊음 */
export function buildWeekChart(data: ResourceData | null, now = Date.now()): HTMLElement {
const W = 720;
const H = 180;
const pad = { l: 28, r: 8, t: 8, b: 20 };
const start = now - 7 * DAY_MS;
const x = (t: number): number => pad.l + ((t - start) / (now - start)) * (W - pad.l - pad.r);
const y = (v: number): number => pad.t + (1 - v / 100) * (H - pad.t - pad.b);
const box = h("div", "b01-pg-card b01-sys-chart");
const legend = h("div", "b01-sys-chart__legend");
for (const s of SERIES) {
const item = h("span", "", s.name);
const dot = h("i");
dot.style.background = s.color;
item.prepend(dot);
legend.append(item);
}
const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });
chart.setAttribute("aria-label", "최근 7 일 CPU · 메모리 · 디스크 사용률");
for (const v of [0, 50, 100]) {
chart.append(svg("line", { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v) }));
const label = svg("text", { x: pad.l - 4, y: y(v) + 3, "text-anchor": "end" });
label.textContent = `${v}`;
chart.append(label);
}
// 날짜 눈금 — 자정마다
const midnight = new Date(start);
midnight.setHours(24, 0, 0, 0);
for (let t = midnight.getTime(); t <= now; t += DAY_MS) {
const d = new Date(t);
const label = svg("text", { x: x(t), y: H - 6, "text-anchor": "middle" });
label.textContent = `${d.getMonth() + 1}/${d.getDate()}`;
chart.append(label);
}
const points = (data?.history ?? [])
.filter((p) => p.timestamp)
.map((p) => ({
t: new Date(p.timestamp.endsWith("Z") ? p.timestamp : `${p.timestamp}Z`).getTime(),
p,
}))
.filter(({ t }) => t >= start && t <= now)
.sort((a, b) => a.t - b.t);
for (const s of SERIES) {
let d = "";
let prev = -Infinity;
for (const { t, p } of points) {
const v = p[s.key];
if (v == null) continue;
d += `${t - prev > 3 * 3_600_000 ? "M" : "L"}${x(t).toFixed(1)} ${y(v).toFixed(1)} `;
prev = t;
chart.append(
svg("circle", { cx: x(t).toFixed(1), cy: y(v).toFixed(1), r: 1.5, fill: s.color }),
);
}
chart.append(svg("path", { d, fill: "none", stroke: s.color, "stroke-width": 1.5 }));
}
box.append(legend, chart);
if (!points.length) box.append(h("p", "b01-pg-note", "7 일 안에 쌓인 기록이 없습니다."));
return box;
}
export function mountSysOverview(host: HTMLElement, _ctx: ViewCtx): void {
loadPage(host, async (stage) => {
const [res, companies, users] = await Promise.all([
fetchSystemResources(7),
fetchAllCompanies().catch(() => []),
fetchAllUsers(null).catch(() => []),
]);
const now = res.current;
const stats = h("div", "b01-sys-stats");
stats.append(
statCard("회사", companies.length),
statCard("사용자", users.length),
statCard("활성 프로젝트", now?.active_project_count ?? 0),
statCard("활성 사용자", now?.active_user_count ?? 0),
);
const gauges = h("div", "b01-sys-stats");
gauges.append(
gauge("CPU", now?.cpu_usage_percent),
gauge("메모리", now?.memory_usage_percent),
gauge("디스크", now?.disk_usage_percent),
);
const page = pageFrame(
stage,
"개요",
"서버 상태와 회사 · 사용자 · 프로젝트 수를 한눈에 봅니다.",
);
page.body.append(stats, gauges, buildWeekChart(res));
});
}
@@ -0,0 +1,162 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysUsers.ts
* 시스템 관리 › 사용자(549-2) — 전체 사용자 표 · 거르기(회사 · 역할 · 찾기) · [+ 사용자] · 줄 끝 ⋯(수정 · 역할 변경 · 삭제).
* 회사 거르기 = 서버가 그 회사 사람만 줌(전체는 최근 200 명) · 역할 · 찾기 = 받은 줄 안에서.
* 주소 `&company=<id>&q=<글>` 로 미리 거른 채 열 수 있음(회사 상세 › 사람).
* ========================================================================== */
import { currentQuery } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
type CompanyInfo,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openAddMemberModal,
openChangeRoleModal,
openDeleteUserModal,
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
dataTable,
emptyState,
nameCell,
pageFrame,
primaryButton,
rowMenu,
type RowMenuItem,
} from "./B01_Dashboard_UI_PageKit";
import { el, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
const SERVER_LIMIT = 200;
/** 역할 · 찾기(이름 · 이메일 · 회사 · 부서 · 직급) 거르기 — 순서는 받은 그대로 */
export function filterUsers(users: DashboardUser[], role: string, query: string): DashboardUser[] {
const q = query.trim().toLowerCase();
return users.filter(
(u) =>
(!role || u.role === role) &&
(!q ||
[u.name, u.email, u.company_name, u.department, u.position]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(q)),
);
}
const COLUMNS = [
{ label: "이름" },
{ label: "회사" },
{ label: "직급" },
{ label: "부서" },
{ label: "전화" },
{ label: "역할" },
{ label: "상태" },
{ label: "" },
];
function menuOf(ctx: ViewCtx, u: DashboardUser): RowMenuItem[] {
const items: RowMenuItem[] = [
{ label: "정보 수정", onClick: () => openEditUserModal(ctx.me, u) },
];
if (canChangeRole(ctx.me, u))
items.push({ label: "역할 변경", onClick: () => openChangeRoleModal(u) });
// 회사에서 빼기 · 계정 삭제 — 처리 방식 고르는 창이 확인을 겸함 · 자기 자신은 안 보임
if (canDeleteUser(ctx.me, u) && u.id !== ctx.me.id)
items.push({ label: "삭제…", onClick: () => openDeleteUserModal(u) });
return items;
}
export function mountSysUsers(host: HTMLElement, ctx: ViewCtx): void {
const start = currentQuery();
const state = {
company: start.get("company") ?? "",
role: "",
q: (start.get("q") ?? "").toLowerCase(),
};
loadPage(host, async (stage) => {
const companies: CompanyInfo[] = await fetchAllCompanies().catch(() => []);
let users: DashboardUser[] = [];
const body = el("div");
const count = el("span", "b01-sys-count");
const draw = (): void => {
const shown = filterUsers(users, state.role, state.q);
const capped = users.length >= SERVER_LIMIT && !state.company;
count.textContent = `${shown.length} / ${users.length} 명${capped ? " · 최근 200 명까지 — 회사로 거르면 더 보임" : ""}`;
body.replaceChildren(
shown.length
? dataTable(
COLUMNS,
shown.map((u) => {
const st = statusBadgeOf(u.status);
return [
nameCell(u.name, u.email),
u.company_name ?? "-",
u.position ?? "-",
u.department ?? "-",
u.phone ?? "-",
badge(roleText(u.role), u.role === "USER" ? "neutral" : "accent"),
badge(statusLabel(u.status, st.text), st.tone),
rowMenu(menuOf(ctx, u)),
];
}),
)
: emptyState({
icon: "user",
text: users.length ? "조건에 맞는 사용자가 없습니다." : "사용자가 없습니다.",
}),
);
};
const load = async (): Promise<void> => {
users = await fetchAllUsers(state.company ? Number(state.company) : null);
draw();
};
await load();
const company = selectBox(
"회사",
[["", "모든 회사"], ...companies.map((c): [string, string] => [String(c.id), c.name])],
(v) => {
state.company = v;
void load();
},
);
company.value = state.company;
const role = selectBox(
"역할",
[
["", "모든 역할"],
["SYSTEM_ADMIN", "시스템 관리자"],
["ADMIN", "회사 관리자"],
["USER", "일반 사용자"],
],
(v) => {
state.role = v;
draw();
},
);
const search = searchBox("이름 · 이메일 · 부서 찾기", (v) => {
state.q = v;
draw();
});
search.value = state.q;
const page = pageFrame(stage, "사용자", "모든 회사의 사용자를 보고 정보 · 역할을 고칩니다.");
page.setAction(
primaryButton("사용자", () =>
openAddMemberModal(undefined, state.company ? Number(state.company) : ctx.companyId),
),
);
page.body.append(toolbar(search, company, role, count), body);
});
}
/** 계정 상태 글 — 공용 배지 글에 없는 값(회사 없음)만 덧붙임 */
function statusLabel(status: string, fallback: string): string {
return status === "NO_COMPANY" ? "회사 없음" : fallback;
}
+12 -6
View File
@@ -15,6 +15,12 @@ import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
import { mountProjectDetail } from "./B01_Dashboard_UI_ProjectDetail";
import { mountProjectList } from "./B01_Dashboard_UI_ProjectList";
import { mountMe } from "./B01_Dashboard_UI_View_Me";
import { mountSysCompanies } from "./B01_Dashboard_UI_View_SysCompanies";
import { mountSysDefaults } from "./B01_Dashboard_UI_View_SysDefaults";
import { mountSysLogs } from "./B01_Dashboard_UI_View_SysLogs";
import { mountSysOverview } from "./B01_Dashboard_UI_View_SysOverview";
import { mountSysUsers } from "./B01_Dashboard_UI_View_SysUsers";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
@@ -59,16 +65,16 @@ export const VIEWS: View[] = [
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: legacy("company") },
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: legacy("companyDefaults") },
// 시스템 관리
{ key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: legacy("sysOverview") },
{ key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: legacy("sysCompanies") },
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: legacy("sysUsers") },
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: legacy("sysDefaults") },
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: legacy("sysLogs") },
{ key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: mountSysOverview },
{ key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: mountSysCompanies },
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: mountSysUsers },
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: mountSysDefaults },
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: mountSysLogs },
// 도움
{ key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide },
{ key: "chat", title: "지식 챗봇", group: "help", roles: ALL, icon: "chatbot", mount: mountChat },
// 사용자 메뉴
{ key: "me", title: "내 정보", group: "hidden", roles: ALL, icon: "userCircle", mount: legacy("me") },
{ key: "me", title: "내 정보", group: "hidden", roles: ALL, icon: "userCircle", mount: mountMe },
];
/** 역할 가르기 — 옛 B01 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */
+133
View File
@@ -0,0 +1,133 @@
"""549-2 시스템 관리 · 내 정보 세부 페이지 시험 — 화면 조작은 ORCA.
· 거르기 · 게이지 색 · 로그 글 = 순수 함수를 Node 로 바로 돌림
· 여섯 열쇠가 새 페이지로 갈아 끼워짐 · 시스템 관리 페이지는 시스템 관리자만(등록부 + 서버 둘 다)
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B01 = ROOT / "B01_Dashboard"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _slice(name: str, start: str, end: str) -> str:
src = _text(B01 / name)
return src[src.index(start) : src.index(end)]
def _node(script: str) -> object:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@needs_node
def test_게이지_색_선():
src = _slice(
"B01_Dashboard_UI_View_SysOverview.ts", "export const GAUGE_WARN", "function statCard"
)
out = _node(
src + "console.log(JSON.stringify([null, 0, 69.9, 70, 89.9, 90, 100].map(gaugeTone)));"
)
assert out == ["ok", "ok", "ok", "warn", "warn", "bad", "bad"]
@needs_node
def test_사용자_거르기():
src = _slice("B01_Dashboard_UI_View_SysUsers.ts", "/** 역할 · 찾기", "const COLUMNS")
users = [
{"id": 1, "name": "홍길동", "email": "hong@a.kr", "company_name": "예시건설", "role": "ADMIN"},
{"id": 2, "name": "김영희", "email": "kim@b.kr", "company_name": None, "role": "USER"},
{"id": 3, "name": "관리", "email": "sys@c.kr", "department": "운영", "role": "SYSTEM_ADMIN"},
] # fmt: skip
out = _node(
"type DashboardUser = any;\n"
+ src
+ f"const u = {json.dumps(users, ensure_ascii=False)};"
+ "console.log(JSON.stringify(["
"filterUsers(u, '', '').map(x => x.id),"
"filterUsers(u, 'USER', '').map(x => x.id),"
"filterUsers(u, '', '예시').map(x => x.id),"
"filterUsers(u, '', 'KIM@B').map(x => x.id),"
"filterUsers(u, '', 'null').map(x => x.id),"
"filterUsers(u, 'ADMIN', '운영').map(x => x.id)]));"
)
assert out == [[1, 2, 3], [2], [1], [2], [], []]
@needs_node
def test_로그_거르기_글():
src = _slice("B01_Dashboard_UI_View_SysLogs.ts", "export const ACTION_KO", "function dateInput")
logs = [
{"action": "PROJECT_CREATE", "email": "a@x.kr", "resource_type": "project",
"resource_ref": "12345678-aaaa-bbbb-cccc-1234567890ab", "ip_address": "1.2.3.4",
"timestamp": "2026-10-20T09:00:00"},
{"action": "COMPANY_CREATE", "email": "b@x.kr", "resource_type": "COMPANY",
"resource_id": 7, "ip_address": None, "timestamp": "2026-10-25T10:00:00"},
{"action": "NEW_THING", "email": None, "timestamp": "2026-10-26T11:00:00"},
] # fmt: skip
out = _node(
"type AuditLog = any;\n"
+ src
+ f"const l = {json.dumps(logs, ensure_ascii=False)};"
+ "const f = (o: object) => filterLogs(l, { action: '', from: '', to: '', q: '', ...o }).length;"
+ "console.log(JSON.stringify(["
"actionLabel('PROJECT_CREATE'), actionLabel('NEW_THING'),"
"targetText(l[0]), targetText(l[1]), targetText(l[2]),"
"f({}), f({ action: 'COMPANY_CREATE' }),"
"f({ from: '2026-10-25' }), f({ to: '2026-10-20' }), f({ from: '2026-10-25', to: '2026-10-25' }),"
"f({ q: '프로젝트 생성' }), f({ q: '1.2.3' })]));"
)
assert out == [
"프로젝트 생성", "NEW_THING", "프로젝트 12345678…", "회사 7", "-",
3, 1, 2, 1, 1, 1, 1,
] # fmt: skip
def test_여섯_열쇠_새_페이지():
views = _text(B01 / "B01_Dashboard_UI_Views.ts")
want = {
"sys-overview": "mountSysOverview", "sys-companies": "mountSysCompanies",
"sys-users": "mountSysUsers", "sys-defaults": "mountSysDefaults",
"sys-logs": "mountSysLogs", "me": "mountMe",
} # fmt: skip
for key, mount in want.items():
line = next(x for x in views.splitlines() if f'key: "{key}"' in x)
assert f"mount: {mount} " in line + " ", key
if key != "me":
assert 'roles: ["SYSTEM_ADMIN"]' in line, key # 시스템 관리 = 시스템 관리자만
legacy = _text(B01 / "B01_Dashboard_UI_Legacy.ts")
for old in ("sysOverview", "sysCompanies", "sysUsers", "sysDefaults", "sysLogs", " me: (s)"):
assert old not in legacy, old # 옛 구역 지움
def test_서버도_시스템_관리자만():
router = _text(B01 / "B01_Dashboard_Router.py")
for route in ("/admin/companies", "/admin/users", "/admin/audit-logs", "/admin/resources"):
block = re.search(r'@router\.get\("%s"\)[\s\S]*?\n\n\n' % re.escape(route), router)
assert block and "Depends(require_system_admin)" in block.group(0), route
def test_로그_쪽_불러오기_있음():
api = _text(B01 / "B01_Dashboard_Api_Fetch.ts")
assert "export async function fetchAuditLogPage(" in api
assert "/dashboard/admin/audit-logs?page=${page}&limit=${limit}" in api