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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? "",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user