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:
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user