diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index bc3fbb138..74d771a12 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -495,6 +495,14 @@ export async function fetchAuditLogs(): Promise { 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 { return request(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Legacy.ts b/B01_Dashboard/B01_Dashboard_UI_Legacy.ts index d06f2bd88..25792bb8a 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Legacy.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Legacy.ts @@ -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 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; diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index f7c39ec3b..474dfdab7 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -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]); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Resources.ts b/B01_Dashboard/B01_Dashboard_UI_Resources.ts deleted file mode 100644 index 1cdea5195..000000000 --- a/B01_Dashboard/B01_Dashboard_UI_Resources.ts +++ /dev/null @@ -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; -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css index 8d63f3c6f..af88305ca 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css @@ -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); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Me.ts b/B01_Dashboard/B01_Dashboard_UI_View_Me.ts new file mode 100644 index 000000000..c81954e0b --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_Me.ts @@ -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))] : []), + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_Parts.ts b/B01_Dashboard/B01_Dashboard_UI_View_Parts.ts new file mode 100644 index 000000000..4f3e4e245 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_Parts.ts @@ -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 = ( + 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 { + 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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysCompanies.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysCompanies.ts new file mode 100644 index 000000000..0fc0cfe63 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysCompanies.ts @@ -0,0 +1,228 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_SysCompanies.ts + * 시스템 관리 › 회사(549-2) — 회사 목록(찾기 · 줄 끝 ⋯) → 회사 상세(정보 · 사람 · 프로젝트 탭) · [+ 회사]. + * 상세 = 주소 `&company=`(뒤로 가기 됨) · 수정 · 로고 · 새 회사 = 지금 창(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 = { + 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 { + 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 = { 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); + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts new file mode 100644 index 000000000..df9a8a70e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts @@ -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 = { + 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(); + 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)))); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts new file mode 100644 index 000000000..4e16dcf98 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysLogs.ts @@ -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 = { + PROJECT_CREATE: "프로젝트 생성", + PROJECT_UPDATE: "프로젝트 수정", + PROJECT_DELETE: "프로젝트 삭제", + PROJECT_HARD_DELETE: "프로젝트 영구 삭제", + COMPANY_CREATE: "회사 생성", + CALC_SITE_CHANGE: "계산 자리 변경", + RATE_ANOMALY: "호출 이상", + M02_OTHER_LAYER_WRITE: "마스터 다른 층 수정", +}; +const TARGET_KO: Record = { + 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 { + 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); + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysOverview.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysOverview.ts new file mode 100644 index 000000000..c1d9578e9 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysOverview.ts @@ -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): 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)); + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_View_SysUsers.ts b/B01_Dashboard/B01_Dashboard_UI_View_SysUsers.ts new file mode 100644 index 000000000..a2aa4f0bf --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_View_SysUsers.ts @@ -0,0 +1,162 @@ +/* ============================================================================= + * B01_Dashboard_UI_View_SysUsers.ts + * 시스템 관리 › 사용자(549-2) — 전체 사용자 표 · 거르기(회사 · 역할 · 찾기) · [+ 사용자] · 줄 끝 ⋯(수정 · 역할 변경 · 삭제). + * 회사 거르기 = 서버가 그 회사 사람만 줌(전체는 최근 200 명) · 역할 · 찾기 = 받은 줄 안에서. + * 주소 `&company=&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 => { + 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; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Views.ts b/B01_Dashboard/B01_Dashboard_UI_Views.ts index 0e051c5f6..de05ac3f4 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Views.ts @@ -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 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */ diff --git a/resources/tester/test_549_b01_sys_pages.py b/resources/tester/test_549_b01_sys_pages.py new file mode 100644 index 000000000..2729a46f1 --- /dev/null +++ b/resources/tester/test_549_b01_sys_pages.py @@ -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