feat(B01): 대시보드 표·기본정보 정비 (2026-09-06 사용자 지시)

- 프로젝트 표의 진행도(%) 열 삭제 — 워크플로 배지가 같은 것을 보여 줌. 상태 문자열로
  따로 세던 서버 계산도 제거(배지는 `project_workflow_stages` 표가 근거라 둘이 어긋났음)
- 시스템 로그 표 = 이메일 · 동작 · 대상 · 일시. 「관리」 문구를 돌려 쓰던 것을 가르고,
  대상(resource_type·id)을 새로 보이며, 일시는 날짜/시각 두 줄(아랫줄 작은 글씨라 행 높이 불변)
- 표 안 관리 버튼을 한 줄로 — 작은 버튼 + 줄바꿈 금지, 폭이 모자라면 표가 가로 스크롤
  (프로젝트 행 높이 105px → 61px, 사용자 행 55px)
- 기본정보는 로그인 본인 화면 — 이메일 라벨을 「팀원 이메일」에서 「이메일」로 바꾸고
  본인 서명 칸을 사용자 수정 모달과 같은 부품으로 추가
- 제목·여백을 공용 템플릿(`createGeneralLayout`)으로 통일, 역할 배지는 제목 줄 오른쪽

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 18:41:02 +09:00
co-authored by Claude Opus 5
parent 92336db3c2
commit d50956381d
9 changed files with 169 additions and 63 deletions
-1
View File
@@ -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;
}
+10 -5
View File
@@ -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]]:
+34 -4
View File
@@ -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<string, string> = {
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,
);
}
+38 -8
View File
@@ -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 ?? "",
});
+15 -19
View File
@@ -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<void> {
}
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;
}
+30 -3
View File
@@ -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;
}
+1 -2
View File
@@ -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,
+37 -20
View File
@@ -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);
+4 -1
View File
@@ -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"],