diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index b2795995..2d4e01a6 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -63,7 +63,6 @@ export interface ProjectItem { member_user_ids?: number[]; owner_name?: string | null; workflow_stage: number; - progress_percent: number; workflow_state?: WorkflowState; updated_at?: string | null; } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 106f1fa6..fc772cbf 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -42,10 +42,16 @@ async def role_for_company(company_id: int | None) -> str: return "USER" -def _stage_from_status(status: str | None) -> tuple[int, int]: +def _stage_from_status(status: str | None) -> int: + """프로젝트 상태 문자열에서 워크플로 단계만 뽑는다. + + 진행도(%)는 내지 않는다 (2026-09-06 사용자 지시) — 화면은 워크플로 배지로 보여 주고, + 배지는 `project_workflow_stages` 표를 근거로 삼는다. 상태 문자열로 따로 세면 근거가 + 둘이 되어 배지와 숫자가 어긋났다. + """ value = status or "NEW" if value in {"WF1_ANALYZING", "WF1_FAILED"}: - return 1, round(1 / 7 * 100) + return 1 order = [ ("FILE_UPLOADED", 1), ("WF1_COMPLETE", 2), @@ -61,12 +67,11 @@ def _stage_from_status(status: str | None) -> tuple[int, int]: for token, idx in order: if token in value: stage = max(stage, idx) - return stage, round(stage / 7 * 100) + return stage def _project_row(row: dict[str, Any]) -> dict[str, Any]: - stage, progress = _stage_from_status(row.get("status")) - return {**row, "workflow_stage": stage, "progress_percent": progress} + return {**row, "workflow_stage": _stage_from_status(row.get("status"))} async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 3da6941b..5bd3cbd4 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -7,7 +7,13 @@ import { openEditUserModal, } from "./B01_Dashboard_UI_Modals"; import { table, text } from "@ui/ui_template_general_blocks"; -import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common"; +import { + DASHBOARD_VISIBLE_ROWS, + formatDate, + formatTime, + L, + stackedCell, +} from "./B01_Dashboard_UI_Common"; export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { return table( @@ -69,14 +75,38 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H ); } +/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */ +function auditTarget(log: AuditLog): string { + // 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다. + const key = (log.resource_type ?? "").toUpperCase(); + const kind = TARGET_LABELS[key] ?? log.resource_type ?? ""; + if (!kind) return "-"; + return log.resource_id ? `${kind} #${log.resource_id}` : kind; +} + +const TARGET_LABELS: Record = { + PROJECT: "프로젝트", + COMPANY: "회사", + USER: "사용자", + ASSET: "자산", +}; + export function auditLogTable(logs: AuditLog[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), - L("B01_Dashboard_Table_Action"), - L("B01_Dashboard_Table_Updated"), + // 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적). + L("B01_Dashboard_Table_Event"), + L("B01_Dashboard_Table_Target"), + L("B01_Dashboard_Table_When"), ], - logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), + logs.map((log) => [ + text(log.email), + text(log.action), + text(auditTarget(log)), + // 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다. + stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)), + ]), DASHBOARD_VISIBLE_ROWS, ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 17f51a19..b7ba551d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -40,6 +40,32 @@ export function formatDate(value?: string | null): string { return value ? value.slice(0, 10) : "-"; } +/** 시:분 — 표에서 날짜 아래 줄에 붙인다 (2026-09-06 사용자 지시). */ +export function formatTime(value?: string | null): string { + if (!value) return ""; + const time = value.includes("T") ? value.split("T")[1] : value.slice(11); + return time ? time.slice(0, 5) : ""; +} + +/** + * 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**. + * 날짜/시각처럼 한 칸에 두 값을 넣을 때 쓴다. + */ +export function stackedCell(top: string, bottom: string): HTMLElement { + const cell = document.createElement("div"); + cell.className = "b01-dashboard__stacked"; + const first = document.createElement("span"); + first.textContent = top; + cell.append(first); + if (bottom) { + const second = document.createElement("span"); + second.className = "b01-dashboard__stacked-sub"; + second.textContent = bottom; + cell.append(second); + } + return cell; +} + /* ----------------------------------------------------------------------------- * 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시) * @@ -149,13 +175,17 @@ export function attachModalDismiss( * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 * 이메일은 계정 식별자라 읽기 전용이다. */ -export function buildUserFields(source: { - name: string; - email?: string; - position?: string | null; - department?: string | null; - phone?: string | null; -}): { +export function buildUserFields( + source: { + name: string; + email?: string; + position?: string | null; + department?: string | null; + phone?: string | null; + }, + /** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */ + options: { self?: boolean } = {}, +): { grid: HTMLElement; validate: () => boolean; values: () => { @@ -175,7 +205,7 @@ export function buildUserFields(source: { value: source.position ?? "", }); const email = createInputField({ - label: L("B01_Dashboard_Field_MemberEmail"), + label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"), type: "email", value: source.email ?? "", }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 6e821eea..6b445015 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -7,6 +7,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -130,9 +131,8 @@ async function loadRoleData(state: DashboardState): Promise { } function buildPage(state: DashboardState): HTMLElement { + // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); - page.className = "b01-dashboard"; - page.append(buildHeader(state.user)); const grid = document.createElement("div"); grid.className = "b01-dashboard__grid"; @@ -185,22 +185,18 @@ function buildPage(state: DashboardState): HTMLElement { grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); } page.append(grid); - return page; -} - -function buildHeader(user: DashboardUser): HTMLElement { - const header = document.createElement("header"); - header.className = "b01-dashboard__header"; - const text = document.createElement("div"); - const title = document.createElement("h1"); - title.className = "b01-dashboard__title"; - title.textContent = L("B01_Dashboard_Title"); - const subtitle = document.createElement("p"); - subtitle.className = "b01-dashboard__subtitle"; - subtitle.textContent = L("B01_Dashboard_Subtitle"); - text.append(title, subtitle); - const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral"); + const layout = createGeneralLayout({ + pageClass: "b01-dashboard", + title: L("B01_Dashboard_Title"), + subtitle: L("B01_Dashboard_Subtitle"), + content: page, + }); + // 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다). + const tag = createTag( + roleLabel(state.user.role), + state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral", + ); tag.classList.add("b01-dashboard__role"); - header.append(text, tag); - return header; + layout.root.querySelector(".ui-general-layout__header")?.append(tag); + return layout.root; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index 90db3ab9..f7c39ec3 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -1,13 +1,40 @@ import { isBlank } from "@util/common_util_validate"; import { createButton, createInputField } from "@ui/ui_template_elements"; -import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { + changePassword, + fetchCompanyAssets, + updateCompanyAsset, + updateUserProfile, + type DashboardUser, +} from "./B01_Dashboard_Api_Fetch"; +import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common"; const PASSWORD_MIN_LENGTH = 8; export function buildProfileForm(user: DashboardUser): HTMLElement { - const fields = buildUserFields(user); + // 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시). + const fields = buildUserFields(user, { self: true }); const grid = fields.grid; + // 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때 + // 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시). + const signatureSlot = document.createElement("div"); + 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( + 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); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id }); + }, + }).root, + ); + }); + } const save = createButton({ label: L("B01_Dashboard_SaveProfile"), onClick: async function onB01_Profile_Save_Click() { @@ -16,7 +43,7 @@ export function buildProfileForm(user: DashboardUser): HTMLElement { }, }); const wrap = document.createElement("div"); - wrap.append(grid, save); + wrap.append(grid, signatureSlot, save); return wrap; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index 7352226e..a07fe807 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -13,7 +13,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser [ L("B01_Dashboard_Table_Project"), L("B01_Dashboard_Table_Region"), - L("B01_Dashboard_Table_Progress"), + // 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다. L("B01_Dashboard_Table_Workflow"), L("B01_Dashboard_Table_Updated"), L("B01_Dashboard_Table_Action"), @@ -44,7 +44,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser return [ text(project.name), text(project.region), - text(`${project.progress_percent}%`), workflow(project), text(formatDate(project.updated_at)), actCell, diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 15fde60c..9eb0b919 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -1,28 +1,19 @@ -.b01-dashboard { - max-width: var(--page-max-width); - margin: 0 auto; - padding: var(--spacing-40) var(--spacing-24) var(--spacing-64); +/* 폭·제목·여백은 공용 템플릿(ui_template_general_layout)이 잡는다 (2026-09-06 사용자 지시). + 여기서는 역할 배지를 제목 줄 오른쪽에 세우는 것만 한다. */ +.b01-dashboard .ui-general-layout__header { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; } -.b01-dashboard__header { - display: flex; - justify-content: space-between; - gap: var(--spacing-24); - align-items: flex-end; - margin-bottom: var(--spacing-32); -} - -.b01-dashboard__title { - font-size: var(--text-heading); -} - -.b01-dashboard__subtitle { - margin: var(--spacing-8) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-body); +.b01-dashboard .ui-general-layout__title, +.b01-dashboard .ui-general-layout__subtitle { + grid-column: 1; } .b01-dashboard__role { + grid-column: 2; + grid-row: 1 / span 2; flex-shrink: 0; } @@ -46,6 +37,32 @@ gap: var(--spacing-8); } +/* 표 안의 관리 버튼은 **한 줄로** 세운다 (2026-09-06 사용자 지시) — 두 줄로 접히면 + 행 높이가 두 배가 된다. 버튼을 작게 만들고 줄바꿈을 막되, 폭이 모자라면 표가 + 가로로 스크롤한다(`.b01-dashboard__table-wrap` 이 이미 그렇게 돼 있다). */ +.b01-dashboard__table .b01-dashboard__actions { + flex-wrap: nowrap; + gap: var(--spacing-4); +} + +.b01-dashboard__table .b01-dashboard__actions .ui-btn { + padding: var(--spacing-4) var(--spacing-8); + font-size: var(--text-caption); + white-space: nowrap; +} + +/* 한 칸에 두 줄(날짜/시각) — 아랫줄이 작아 행 높이는 한 줄일 때와 같다. */ +.b01-dashboard__stacked { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.b01-dashboard__stacked-sub { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + .b01-dashboard__table-wrap { overflow-x: auto; border: 1px solid var(--color-border); diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index af121860..e12e1569 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -125,7 +125,6 @@ export const ui_locales_b1 = { B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], B01_Dashboard_Table_Project: ["프로젝트명", "Project"], B01_Dashboard_Table_Region: ["지역", "Region"], - B01_Dashboard_Table_Progress: ["진행도", "Progress"], B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"], B01_Dashboard_Table_Updated: ["수정일", "Updated"], B01_Dashboard_Table_Email: ["이메일", "Email"], @@ -137,12 +136,16 @@ export const ui_locales_b1 = { B01_Dashboard_Table_Company: ["회사명", "Company"], B01_Dashboard_Table_Requested: ["신청일", "Requested"], B01_Dashboard_Table_Action: ["관리", "Action"], + B01_Dashboard_Table_Event: ["동작", "Event"], + B01_Dashboard_Table_Target: ["대상", "Target"], + B01_Dashboard_Table_When: ["일시", "When"], B01_Dashboard_Table_Owner: ["소유자", "Owner"], B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"], B01_Dashboard_Field_Address: ["주소", "Address"], B01_Dashboard_Field_Owner: ["대표자명", "Owner"], B01_Dashboard_Field_Search: ["검색어", "Search"], B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"], + B01_Dashboard_Field_Email: ["이메일", "Email"], B01_Dashboard_Metric_Cpu: ["CPU", "CPU"], B01_Dashboard_Metric_Memory: ["메모리", "Memory"], B01_Dashboard_Metric_Disk: ["디스크", "Disk"],