feat(B01): 받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값 세부 페이지 (549-1)

- 새 페이지 다섯(View_Assigned · Temp · People · Company · CompanyDefaults) · 등록부 다섯 줄 교체
- 표 줄 40 · 세로선 없음 · 줄 끝 ⋯ · 한글 배지 · 빈 상태 · 사람 탭 가입 요청 수 배지 · 보관함 남은 날 막대(7 일 안 빨강)
- 옛 구역 정리(Legacy · Company · AssignedPlans · TempUpload 다시 씀) · 시험 test_549 · test_546 stub 일반화

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 18:26:27 +09:00
co-authored by Claude Sonnet 5.5
parent 6b0d509077
commit 7e34152de8
16 changed files with 885 additions and 632 deletions
@@ -1,17 +1,10 @@
/* =============================================================================
* B01_Dashboard_UI_AssignedPlans.ts
* 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 열 = 예정노선 · 연도 · 확정(KST) · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결]
* [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 모달(`B01_Dashboard_UI_PlanLink`)
* 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
* 화면 = `B01_Dashboard_UI_View_Assigned.ts` · [연결] 모달 = `B01_Dashboard_UI_PlanLink`
* ========================================================================== */
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createButton, createTag } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { kstText } from "../common_util/common_util_time_kst";
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink";
import { API_BASE_URL } from "@config/config_frontend";
export interface AssignedPlan {
assignee_id: number;
@@ -32,54 +25,3 @@ export async function fetchAssignedPlans(): Promise<AssignedPlan[]> {
if (!response.ok) return [];
return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? [];
}
function linkState(plan: AssignedPlan): HTMLElement {
const cell = document.createElement("div");
cell.className = "b01-dashboard__actions";
cell.append(
createTag(
L(plan.linked_project_id ? "DE_Assigned_Linked" : "DE_Assigned_Unlinked"),
plan.linked_project_id ? "success" : "neutral",
),
);
if (plan.update_available) cell.append(createTag(L("DE_Assigned_Update"), "warning"));
return cell;
}
export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement {
return table(
[
L("DE_New_Plan"),
L("DE_Assigned_Year"),
L("D04_Col_Confirmed"),
L("DE_Assigned_State"),
L("B01_Dashboard_Table_Action"),
],
plans.map((plan) => {
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
const link = createButton({
label: L("DE_Assigned_Link"),
variant: "ghost",
onClick: () =>
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
});
actions.append(
createButton({
label: L("DE_Assigned_View"),
variant: "ghost",
onClick: () => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE),
}),
link,
);
return [
text(plan.project_name),
text(plan.year),
text(kstText(plan.confirmed_at)),
linkState(plan),
actions,
];
}),
DASHBOARD_VISIBLE_ROWS,
);
}
+5 -154
View File
@@ -1,163 +1,14 @@
import { createButton, createTag } from "@ui/ui_template_elements";
import { createButton } from "@ui/ui_template_elements";
import {
fetchCompanyAssets,
processJoinRequest,
setCompanyLogo,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openChangeRoleModal,
openEditCompanyModal,
openCreateCompanyModal,
openDeleteUserModal,
openEditUserModal,
openFindCompanyModal,
} from "./B01_Dashboard_UI_Modals";
import type { DashboardState } from "./B01_Dashboard_UI_Page";
import { actionPair, table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
export function buildCompanyPanel(state: DashboardState): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__actions";
if (!state.company) {
wrap.append(
createTag(L("B01_Dashboard_NoCompany"), "warning"),
createButton({
label: L("B01_Dashboard_CreateCompany"),
onClick: () => openCreateCompanyModal(),
}),
createButton({
label: L("B01_Dashboard_FindCompany"),
variant: "ghost",
onClick: () => openFindCompanyModal(),
}),
);
return wrap;
}
wrap.append(
createTag(`${state.company.name} (${state.user.status})`, "success"),
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
);
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
if (state.user.role !== "USER" && state.company) {
const company = state.company;
wrap.append(
createButton({
label: "회사 정보 수정",
variant: "ghost",
onClick: () => openEditCompanyModal(company),
}),
);
}
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
if (state.user.role !== "USER") {
const company = state.company;
const slot = document.createElement("div");
void fetchCompanyAssets(company.id).then((assets) => {
slot.append(
createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
company.logo_asset_id ?? null,
company.id,
state.user,
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
).root,
);
});
wrap.append(slot);
}
return wrap;
}
export function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Email"),
L("B01_Dashboard_Table_Name"),
L("B01_Dashboard_Table_Position"),
L("B01_Dashboard_Table_Department"),
L("B01_Dashboard_Table_Role"),
L("B01_Dashboard_Table_Action"),
],
members.map((member) => {
const actionsEl = document.createElement("div");
actionsEl.className = "b01-dashboard__actions";
actionsEl.append(
createButton({
label: L("Common_Btn_Edit"),
variant: "ghost",
onClick: () => openEditUserModal(currentUser, member),
}),
);
if (canChangeRole(currentUser, member)) {
actionsEl.append(
createButton({
label: L("B01_Dashboard_ChangeRole"),
variant: "ghost",
onClick: () => openChangeRoleModal(member),
}),
);
}
if (canDeleteUser(currentUser, member)) {
actionsEl.append(
createButton({
label: L("B01_Dashboard_RemoveMember"),
variant: "danger",
onClick: () => openDeleteUserModal(member),
}),
);
}
return [
text(member.email),
text(member.name),
text(member.position),
text(member.department),
text(member.role),
actionsEl,
];
}),
DASHBOARD_VISIBLE_ROWS,
);
}
export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Email"),
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
L("B01_Dashboard_Table_Requested"),
L("B01_Dashboard_Table_Status"),
L("B01_Dashboard_Table_Action"),
],
requests.map((request) => [
text(request.user_email),
...(systemMode ? [text(request.company_name)] : []),
text(formatDate(request.requested_at)),
text(request.status),
actionPair(
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
),
]),
DASHBOARD_VISIBLE_ROWS,
);
}
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
import { openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
import { table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, L, runRequest } from "./B01_Dashboard_UI_Common";
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
+3 -82
View File
@@ -14,55 +14,36 @@ import {
fetchAllUsers,
fetchAuditLogs,
fetchCompanyJoinRequests,
fetchCompanyMembers,
fetchCompanyProjects,
fetchSystemResources,
fetchUserCompany,
fetchUserProjects,
type AuditLog,
type CompanyInfo,
type DashboardUser,
type JoinRequest,
type Member,
type ProjectItem,
type ResourceData,
} from "./B01_Dashboard_Api_Fetch";
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import {
assignedPlanTable,
fetchAssignedPlans,
type AssignedPlan,
} from "./B01_Dashboard_UI_AssignedPlans";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { L } from "./B01_Dashboard_UI_Common";
import {
buildCompanyPanel,
companyTable,
joinRequestTable,
memberTable,
} from "./B01_Dashboard_UI_Company";
import { companyTable } from "./B01_Dashboard_UI_Company";
import { openAddMemberModal, openCreateCompanyModal } 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 {
user: DashboardUser;
userProjects: ProjectItem[];
companyProjects: ProjectItem[];
allProjects: ProjectItem[];
company: CompanyInfo | null;
members: Member[];
joinRequests: JoinRequest[];
allCompanies: CompanyInfo[];
allUsers: DashboardUser[];
allJoinRequests: JoinRequest[];
auditLogs: AuditLog[];
resources: ResourceData | null;
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
assignedPlans: AssignedPlan[];
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
adminCompanyId: number | null;
}
@@ -103,23 +84,9 @@ async function loadRoleData(state: DashboardState): Promise<void> {
});
return;
}
const [projects, company, assigned] = await Promise.all([
fetchUserProjects().catch(() => []),
fetchUserCompany().catch(() => null),
fetchAssignedPlans().catch(() => []),
]);
state.userProjects = projects;
state.company = company;
state.assignedPlans = assigned;
state.userProjects = await fetchUserProjects().catch(() => []);
if (state.user.role === "ADMIN") {
const [members, requests, companyProjects] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyJoinRequests().catch(() => []),
fetchCompanyProjects().catch(() => []),
]);
state.members = members;
state.joinRequests = requests;
state.companyProjects = companyProjects;
state.companyProjects = await fetchCompanyProjects().catch(() => []);
}
}
@@ -173,48 +140,6 @@ const SECTIONS: Record<string, (s: DashboardState) => HTMLElement[]> = {
),
];
},
assigned: (s) => [section(L("DE_Assigned_Title"), assignedPlanTable(s.assignedPlans), true)],
temp: () => [buildTempUploadSection()],
people: (s) =>
s.user.role === "SYSTEM_ADMIN"
? [
section(
L("B01_Dashboard_Users"),
userTable(s.allUsers, s.user),
true,
addButton(() => openAddMemberModal(undefined, s.adminCompanyId)),
),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.allJoinRequests, true), true),
]
: [
section(
L("B01_Dashboard_Members"),
memberTable(s.members, s.user),
true,
addButton(() => openAddMemberModal()),
),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.joinRequests, false), true),
],
company: (s) =>
s.user.role === "SYSTEM_ADMIN"
? [
section(
L("B01_Dashboard_Companies"),
companyTable(
s.allCompanies.filter((c) => c.id === s.adminCompanyId),
s.user,
),
true,
),
]
: [section(L("B01_Dashboard_Company"), buildCompanyPanel(s), true)],
companyDefaults: (s) => [
section(
L("B01_Dashboard_SystemSettings"),
buildSystemSettingsPanel(s.user, s.adminCompanyId),
true,
),
],
sysOverview: (s) => [
section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true),
],
@@ -261,15 +186,11 @@ export async function mountLegacy(
userProjects: [],
companyProjects: [],
allProjects: [],
company: null,
members: [],
joinRequests: [],
allCompanies: [],
allUsers: [],
allJoinRequests: [],
auditLogs: [],
resources: null,
assignedPlans: [],
adminCompanyId: null,
};
showLoadingOverlay();
+5 -2
View File
@@ -14,6 +14,7 @@ import "./B01_Dashboard_UI_Style_Pages.css";
const KIT_PATHS = {
dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
plus: "M12 5l0 14|M5 12l14 0",
pencil: "M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4|M13.5 6.5l4 4",
upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12",
mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6",
inbox:
@@ -91,6 +92,8 @@ export interface RowMenuItem {
danger?: boolean;
/** 확인창 글(danger 일 때) */
confirm?: string;
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
ownConfirm?: boolean;
}
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
@@ -117,7 +120,7 @@ export function rowMenu(items: RowMenuItem[]): HTMLElement {
b.setAttribute("role", "menuitem");
b.addEventListener("click", () => {
setOpen(false);
if (!item.danger) return item.onClick();
if (!item.danger || item.ownConfirm) return item.onClick();
void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then(
(ok) => ok && item.onClick(),
);
@@ -126,7 +129,7 @@ export function rowMenu(items: RowMenuItem[]): HTMLElement {
}
trigger.addEventListener("click", (e) => {
e.stopPropagation();
setOpen(pop.hidden);
setOpen(pop.hidden === true);
});
const outside = (e: MouseEvent): void => {
if (!wrap.isConnected) document.removeEventListener("click", outside);
+35 -2
View File
@@ -83,7 +83,7 @@
padding: 0 8px;
text-align: right;
}
.b01-pg-table tr.is-sub td {
.b01-pg-table tr.is-sub > td {
height: auto;
padding: 0;
background: var(--b01-bg);
@@ -254,6 +254,9 @@
flex: none;
max-width: 55%;
}
.b01-pg-row__side .ui-field__label:empty {
display: none;
}
.b01-pg-row__value {
text-align: right;
overflow-wrap: anywhere;
@@ -340,10 +343,11 @@
background: none;
color: var(--b01-muted);
cursor: pointer;
transform: rotate(-90deg);
transition: transform 0.12s;
}
.b01-pg-caret.is-open {
transform: rotate(90deg);
transform: none;
}
.b01-pg-files {
width: 100%;
@@ -422,3 +426,32 @@
font-size: 12.5px;
color: var(--b01-muted);
}
/* 배지 색 — 셸 변수에서 (밝음 · 어두움 같이) */
.b01-pg .ui-tag {
border: 1px solid transparent;
}
.b01-pg .ui-tag--success {
background: var(--b01-accent-soft);
color: var(--b01-accent);
}
.b01-pg .ui-tag--accent {
background: transparent;
border-color: var(--b01-accent);
color: var(--b01-accent);
}
.b01-pg .ui-tag--neutral {
background: var(--b01-hover);
color: var(--b01-muted);
}
.b01-pg .ui-tag--warning {
background: color-mix(in srgb, #d97706 16%, transparent);
color: #b45309;
}
.b01-pg .ui-tag--danger {
background: color-mix(in srgb, var(--b01-alert) 14%, transparent);
color: var(--b01-alert);
}
:root[data-theme="dark"] .b01-pg .ui-tag--warning {
color: #f5a524;
}
+1 -126
View File
@@ -1,133 +1,8 @@
/* =============================================================================
* B01_Dashboard_UI_Style_Temp.css
* 임시 보관함 섹션 — 그룹(임시 프로젝트명) + 하위 파일 표, 등록 모달
* 표·모달 껍데기는 대시보드 공통 스타일을 그대로 쓰고, 여기서는 그룹 카드와
* 진행률 막대만 정의한다. 색·간격은 theme.css 변수만 사용한다.
* 임시 보관함 — 등록 · 파일 추가 모달 조각(목록 표는 `B01_Dashboard_UI_Style_Pages.css`).
* ========================================================================== */
/* 제목 + 보관 기간 태그를 한 묶음으로 — 공용 섹션 헤더의 왼쪽 칸에 들어간다. */
.b01-temp__section-title {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.b01-temp__list {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
/* 그룹 = 임시 프로젝트명 1건. 아래에 파일 표가 붙는다. */
.b01-temp__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-paper);
}
/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 지금 쓸 수 있는 자료와 구분한다. */
.b01-temp__group.is-linked {
opacity: 0.6;
}
/* 제목 행 전체가 접기/펼치기 트리거다. */
.b01-temp__group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
flex-wrap: wrap;
cursor: pointer;
}
.b01-temp__group-title {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.b01-temp__caret {
width: 12px;
font-family: var(--font-body);
font-size: var(--text-caption);
line-height: 1;
color: var(--color-text-muted);
}
.b01-temp__group-meta {
font-family: var(--font-body);
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* 기본은 접힘 — 펼친 그룹만 파일 표를 보여 준다. */
.b01-temp__group-body {
display: none;
margin-top: var(--spacing-8);
}
.b01-temp__group.is-open .b01-temp__group-body {
display: block;
}
/* 파일 표는 행이 많아 촘촘하게 — 글자 크기는 그대로 두고 상하 여백만 줄인다. */
.b01-temp__group-body .ui-general-block__table th,
.b01-temp__group-body .ui-general-block__table td {
padding-top: 2px;
padding-bottom: 2px;
}
.b01-temp__group-body .ui-general-block__table .ui-btn {
padding-top: 2px;
padding-bottom: 2px;
min-height: 0;
}
.b01-temp__badge {
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
font-size: 11px;
white-space: nowrap;
}
.b01-temp__group.is-completed .b01-temp__badge {
border-color: var(--color-success);
color: var(--color-success);
}
.b01-temp__group.is-uploading .b01-temp__badge {
border-color: var(--color-warning);
color: var(--color-warning);
}
/* 업로드 진행률은 모달이 아니라 해당 파일 행 안에서 보여 준다(2026-08-08 사용자 지시). */
.b01-temp__progress {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 140px;
font-size: 11px;
}
.b01-temp__progress-bar {
height: 6px;
border-radius: 3px;
background: var(--color-canvas);
overflow: hidden;
}
.b01-temp__progress-bar > div {
height: 100%;
background: var(--color-primary);
transition: width 0.2s ease;
}
/* ── 등록/추가 모달 ───────────────────────────────────────────────────── */
.b01-temp__hidden-input {
+2 -7
View File
@@ -12,6 +12,7 @@
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { table, text } from "@ui/ui_template_general_blocks";
import { bytesText } from "./logic/B01_Dashboard_Logic_Pages";
import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
@@ -20,13 +21,7 @@ export function tempFileType(fileName: string): string {
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
}
export function formatTempBytes(bytes: number): string {
const gb = bytes / 1024 / 1024 / 1024;
if (gb >= 1) return `${gb.toFixed(2)} GB`;
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
export const formatTempBytes = bytesText;
export interface TempModalOptions {
title: string;
+205 -186
View File
@@ -1,18 +1,22 @@
/* =============================================================================
* B01_Dashboard_UI_TempUpload.ts
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
*
* 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시):
* - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환
* - 그룹(임시 프로젝트명) 아래에 파일 리스트(표)
* - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음)
* - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
* 임시 보관함 본체(549-1) — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
* 목록 = 묶음 표(이름 · 상태 · 남은 날 막대 · 올린 날 · 줄 끝 ⋯) · 줄을 펼치면 그 안 파일 · 진행률
* ⋯ = 파일 추가 · 삭제(확인) · 파일 줄 ⋯ = 삭제(확인) · 삭제하면 임시 저장소 실제 파일도 지움
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있음
* 화면 틀(머리 · 주 단추)은 `B01_Dashboard_UI_View_Temp.ts`
* ========================================================================== */
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { limitVisibleRows, section, table, text } from "@ui/ui_template_general_blocks";
import { showToast } from "@ui/ui_template_elements";
import { icon } from "@ui/ui_template_icons";
import {
bytesText,
daysLeft,
EXPIRY_WARN_DAYS,
isExpiringSoon,
leftPercent,
} from "./logic/B01_Dashboard_Logic_Pages";
import {
createTempBatch,
createTempUploadSession,
@@ -25,8 +29,18 @@ import {
uploadTempChunk,
type TempBatchItem,
} from "./B01_Dashboard_Api_Temp";
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
import { L } from "./B01_Dashboard_UI_Common";
import {
badge,
emptyState,
nameCell,
primaryButton,
rowMenu,
tableRow,
tableShell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import { openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
import "./B01_Dashboard_UI_Style_Temp.css";
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
@@ -65,40 +79,48 @@ function formatDate(value: string | null): string {
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
}
/**
* 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다.
* 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음).
*/
export function buildTempUploadSection(): HTMLElement {
const panel = document.createElement("div");
panel.className = "b01-temp";
const COLUMNS: Column[] = [
{ label: "보관 이름" },
{ label: "상태", width: "120px" },
{ label: "남은 기간", width: "210px" },
{ label: "올린 날", width: "110px" },
{ label: "", width: "44px" },
];
// 보관 기간은 제목 옆 태그로 알린다(서버가 준 값으로 목록 조회 때 갱신).
const retentionTag = createTag(`${30}${L("B01_Temp_Hint_Days")}`, "neutral");
export interface TempBox {
root: HTMLElement;
/** 새 자료 등록 모달 — 머리 주 단추가 부름 */
openCreate: () => void;
}
/** 보관함 본체 — 표 · 빈 상태 · 업로드를 한 덩이로 · 목록은 안에서 다시 그림(전체 페이지 재렌더 없음) */
export function buildTempBox(onGuide: () => void): TempBox {
const root = document.createElement("div");
root.className = "b01-pg__body";
const note = document.createElement("p");
note.className = "b01-pg-note";
const listHost = document.createElement("div");
listHost.className = "b01-temp__list";
panel.append(listHost);
root.append(note, listHost);
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
const tasks = new Map<string, UploadTask[]>();
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
/** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */
/** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */
const openGroups = new Set<string>();
/** 4개 높이 제한을 다시 재는 함수 — 그룹을 펼치면 높이가 달라져 다시 재야 한다. */
let remeasureGroups: (() => void) | null = null;
let retentionDays = 30;
let lastBatches: TempBatchItem[] = [];
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
function progressCell(key: string, percent: number, label: string): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-temp__progress";
const wrap = document.createElement("span");
wrap.className = "b01-pg-progress";
const caption = document.createElement("span");
caption.textContent = `${label} ${Math.round(percent)}%`;
const bar = document.createElement("div");
bar.className = "b01-temp__progress-bar";
const fill = document.createElement("div");
const bar = document.createElement("span");
bar.className = "b01-pg-progress__bar";
const fill = document.createElement("i");
fill.style.width = `${percent}%`;
bar.append(fill);
wrap.append(caption, bar);
@@ -113,181 +135,195 @@ export function buildTempUploadSection(): HTMLElement {
node.caption.textContent = `${L("B01_Temp_File_Status_Uploading")} ${Math.round(task.percent)}%`;
}
function fileRows(batch: TempBatchItem): HTMLElement[][] {
const rows: HTMLElement[][] = [];
for (const file of batch.files) {
// 목록에는 아직 프로젝트에 안 쓴 묶음만 온다 — 항상 지울 수 있다.
const action = createButton({
label: L("Common_Btn_Delete"),
variant: "danger",
onClick: () => void removeFile(batch, file.file_type, file.original_filename),
});
rows.push([
text(file.file_type.toUpperCase()),
text(file.original_filename),
text(formatTempBytes(file.file_size_bytes)),
text(L("B01_Temp_File_Status_Stored")),
action,
]);
function fileLine(cells: (string | Node)[]): HTMLElement {
const tr = document.createElement("tr");
for (const cell of cells) {
const td = document.createElement("td");
td.append(cell);
tr.append(td);
}
return tr;
}
/** 펼친 묶음 안 파일 표 — 올린 파일 · 올리는 중 · 이어올리기 대기 */
function filesTable(batch: TempBatchItem): HTMLElement {
const table = document.createElement("table");
table.className = "b01-pg-files";
const body = document.createElement("tbody");
for (const file of batch.files) {
body.append(
fileLine([
file.file_type.toUpperCase(),
file.original_filename,
bytesText(file.file_size_bytes),
badge(L("B01_Temp_File_Status_Stored"), "success"),
rowMenu([
{
label: L("Common_Btn_Delete"),
danger: true,
confirm: `${file.original_filename}\n${L("B01_Temp_File_Delete_Confirm")}`,
onClick: () => void removeFile(batch, file.file_type),
},
]),
]),
);
}
const running = tasks.get(batch.batch_id) ?? [];
for (const task of running) {
const status =
task.status === "failed"
? text(L("B01_Temp_File_Status_Failed"))
? badge(L("B01_Temp_File_Status_Failed"), "danger")
: task.status === "waiting"
? text(L("B01_Temp_File_Status_Waiting"))
? badge(L("B01_Temp_File_Status_Waiting"), "neutral")
: progressCell(
`${batch.batch_id}::${task.key}`,
task.percent,
L("B01_Temp_File_Status_Uploading"),
);
rows.push([
text(task.fileType.toUpperCase()),
text(task.fileName),
text(formatTempBytes(task.sizeBytes)),
status,
text("-"),
]);
body.append(
fileLine([
task.fileType.toUpperCase(),
task.fileName,
bytesText(task.sizeBytes),
status,
"",
]),
);
}
// 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다.
for (const pending of batch.pending_sessions) {
const known = running.some((task) => task.fileName === pending.original_filename);
if (known) continue;
rows.push([
text(tempFileType(pending.original_filename).toUpperCase()),
text(pending.original_filename),
text(formatTempBytes(pending.file_size_bytes)),
progressCell(
`${batch.batch_id}::pending::${pending.upload_session_id}`,
pending.progress_percent,
L("B01_Temp_File_Status_Paused"),
),
text("-"),
]);
if (running.some((task) => task.fileName === pending.original_filename)) continue;
body.append(
fileLine([
tempFileType(pending.original_filename).toUpperCase(),
pending.original_filename,
bytesText(pending.file_size_bytes),
progressCell(
`${batch.batch_id}::pending::${pending.upload_session_id}`,
pending.progress_percent,
L("B01_Temp_File_Status_Paused"),
),
"",
]),
);
}
return rows;
if (!body.children.length)
body.append(fileLine(["", "파일 없음 · 묶음 ⋯ 에서 파일 추가", "", "", ""]));
table.append(body);
return table;
}
function renderGroup(batch: TempBatchItem): HTMLElement {
const group = document.createElement("div");
group.className = `b01-temp__group is-${batch.status}`;
const opened = openGroups.has(batch.batch_id);
group.classList.toggle("is-open", opened);
const head = document.createElement("div");
head.className = "b01-temp__group-head";
// 제목 행 = 접기/펼치기 트리거. 이름·상태·요약이 한 줄에 들어간다.
const titleRow = document.createElement("div");
titleRow.className = "b01-temp__group-title";
const caret = document.createElement("span");
caret.className = "b01-temp__caret";
caret.textContent = opened ? "▾" : "▸";
const title = document.createElement("strong");
title.textContent = batch.name;
const badge = document.createElement("span");
badge.className = "b01-temp__badge";
badge.textContent = batch.required_complete
? L("B01_Temp_Status_Ready")
: L("B01_Temp_Status_Uploading");
const meta = document.createElement("span");
meta.className = "b01-temp__group-meta";
const parts = [
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
formatTempBytes(batch.total_size_bytes),
];
if (batch.expires_at) {
parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`);
/** 남은 날 칸 — 막대 + 글 · 7 일 안 = 빨강 */
function leftCell(batch: TempBatchItem): HTMLElement {
const left = daysLeft(batch.expires_at);
const cell = document.createElement("span");
// 다 올리기 전에는 만료가 정해지지 않음 — 막대 없이 「-」
if (left === null) {
cell.className = "b01-pg-meta";
cell.textContent = "-";
return cell;
}
meta.textContent = parts.join(" · ");
titleRow.append(caret, title, badge, meta);
cell.className = `b01-pg-left${isExpiringSoon(left) ? " is-soon" : ""}`;
const bar = document.createElement("span");
bar.className = "b01-pg-left__bar";
const fill = document.createElement("i");
fill.style.width = `${leftPercent(left, retentionDays)}%`;
bar.append(fill);
const label = document.createElement("span");
label.textContent = left === 0 ? "오늘 만료" : `${left}일 남음`;
cell.append(bar, label);
return cell;
}
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
// 버튼 클릭이 접기/펼치기까지 건드리지 않게 막는다.
actions.addEventListener("click", (event) => event.stopPropagation());
actions.append(
createButton({
label: L("B01_Temp_Btn_Add"),
variant: "ghost",
onClick: () => openAddModal(batch),
}),
createButton({
label: L("Common_Btn_Delete"),
variant: "danger",
onClick: () => void removeBatch(batch),
}),
);
head.append(titleRow, actions);
head.setAttribute("aria-expanded", String(opened));
head.addEventListener("click", () => {
const nowOpen = !group.classList.contains("is-open");
group.classList.toggle("is-open", nowOpen);
head.setAttribute("aria-expanded", String(nowOpen));
caret.textContent = nowOpen ? "▾" : "▸";
// 목록을 다시 그려도 열어 둔 그룹은 계속 열려 있어야 한다(업로드 중 진행률 확인).
if (nowOpen) openGroups.add(batch.batch_id);
else openGroups.delete(batch.batch_id);
// 펼치면 그 묶음이 커진다 — 4개까지 보이는 높이를 다시 잡는다.
remeasureGroups?.();
function nameWithCaret(batch: TempBatchItem, opened: boolean): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-pg-person";
const caret = document.createElement("button");
caret.type = "button";
caret.className = `b01-pg-caret${opened ? " is-open" : ""}`;
caret.title = caret.ariaLabel = "파일 펼치기";
caret.setAttribute("aria-expanded", String(opened));
caret.append(icon("chevronDown", 14));
caret.addEventListener("click", () => {
if (!openGroups.delete(batch.batch_id)) openGroups.add(batch.batch_id);
draw(lastBatches);
});
const sub = `${batch.files.length}${L("B01_Temp_Meta_FileCount")} · ${bytesText(batch.total_size_bytes)}`;
wrap.append(caret, nameCell(batch.name, sub));
return wrap;
}
const body = document.createElement("div");
body.className = "b01-temp__group-body";
body.append(
table(
[
L("B01_Temp_Table_Type"),
L("B01_Temp_Table_Name"),
L("B01_Temp_Table_Size"),
L("B01_Temp_Table_Status"),
L("B01_Temp_Table_Action"),
],
fileRows(batch),
),
function renderBatch(batch: TempBatchItem, tbody: HTMLElement): void {
const opened = openGroups.has(batch.batch_id);
const status = batch.required_complete
? badge(L("B01_Temp_Status_Ready"), "success")
: badge(L("B01_Temp_Status_Uploading"), "warning");
tbody.append(
tableRow(COLUMNS, [
nameWithCaret(batch, opened),
status,
leftCell(batch),
formatDate(batch.created_at),
rowMenu([
{ label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) },
{
label: L("Common_Btn_Delete"),
danger: true,
confirm: `${batch.name}\n${L("B01_Temp_Delete_Confirm")}`,
onClick: () => void removeBatch(batch),
},
]),
]),
);
if (!opened) return;
const sub = document.createElement("tr");
sub.className = "is-sub";
const cell = document.createElement("td");
cell.colSpan = COLUMNS.length;
cell.append(filesTable(batch));
sub.append(cell);
tbody.append(sub);
}
group.append(head, body);
return group;
function draw(batches: TempBatchItem[]): void {
lastBatches = batches;
progressNodes.clear();
note.textContent = `보관 기간 ${retentionDays}일 · 만료 ${EXPIRY_WARN_DAYS}일 전부터 빨강`;
note.hidden = batches.length === 0;
if (batches.length === 0) {
listHost.replaceChildren(
emptyState({
icon: "archive",
text: "임시 보관함이 비어 있음",
hint: "프로젝트를 만들기 전에 측량 파일을 먼저 올려 둘 수 있음",
action: primaryButton("자료 올리기", openCreateModal, "upload"),
onGuide,
}),
);
return;
}
const shell = tableShell(COLUMNS);
for (const batch of batches) renderBatch(batch, shell.tbody);
listHost.replaceChildren(shell.root);
}
async function refresh(): Promise<void> {
try {
const response = await fetchTempBatches();
retentionTag.textContent = `${response.retention_days}${L("B01_Temp_Hint_Days")}`;
progressNodes.clear();
listHost.replaceChildren();
if (response.batches.length === 0) {
// 빈 상태 문구·서식은 프로젝트 컨테이너와 같게 공용 표 블록을 그대로 쓴다.
listHost.append(table([], []));
return;
}
for (const batch of response.batches) listHost.append(renderGroup(batch));
// 묶음이 쌓여도 컨테이너는 4개 높이까지만 — 나머지는 안쪽 스크롤.
// 표가 아니라 묶음이 한 행이다. 묶음 안 파일 표는 접혀 있어 대상이 아니다.
remeasureGroups = limitVisibleRows(
listHost,
() => [...listHost.querySelectorAll<HTMLElement>(":scope > .b01-temp__group")],
DASHBOARD_VISIBLE_ROWS,
);
retentionDays = response.retention_days;
draw(response.batches);
} catch (error) {
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
}
}
/* ── 삭제 ────────────────────────────────────────────────────────────── */
/* ── 삭제 — 확인은 ⋯ 메뉴가 먼저 받음 ─────────────────────────────────── */
async function removeBatch(batch: TempBatchItem): Promise<void> {
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
try {
await deleteTempBatch(batch.batch_id);
tasks.delete(batch.batch_id);
openGroups.delete(batch.batch_id);
showToast(L("B01_Temp_Delete_Success"), "success");
await refresh();
} catch (error) {
@@ -295,12 +331,7 @@ export function buildTempUploadSection(): HTMLElement {
}
}
async function removeFile(
batch: TempBatchItem,
fileType: string,
fileName: string,
): Promise<void> {
if (!window.confirm(`${fileName}\n${L("B01_Temp_File_Delete_Confirm")}`)) return;
async function removeFile(batch: TempBatchItem, fileType: string): Promise<void> {
try {
await deleteTempBatchFile(batch.batch_id, fileType);
showToast(L("B01_Temp_File_Delete_Success"), "success");
@@ -453,17 +484,5 @@ export function buildTempUploadSection(): HTMLElement {
}
void refresh();
const root = section(L("B01_Temp_Section"), panel, true, [
createButton({ label: "+", onClick: openCreateModal }),
]);
// 공용 섹션 헤더는 [제목 | 액션] 2단이라, 보관 기간 태그를 제목과 한 묶음으로 감싼다.
const heading = root.querySelector("h3");
if (heading) {
const titleWrap = document.createElement("div");
titleWrap.className = "b01-temp__section-title";
heading.replaceWith(titleWrap);
titleWrap.append(heading, retentionTag);
}
return root;
return { root, openCreate: openCreateModal };
}
@@ -0,0 +1,81 @@
/* =============================================================================
* B01_Dashboard_UI_View_Assigned.ts
* 세부 페이지 「받은 예정노선」(549-1) — 산림청이 우리 회사에 지정한 예정노선 연도 표.
* 0 건이어도 메뉴 · 페이지 보임 · 줄 끝 ⋯ = 보기(D02) · 연결(모달)
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
import { kstText } from "../common_util/common_util_time_kst";
import { fetchAssignedPlans, type AssignedPlan } from "./B01_Dashboard_UI_AssignedPlans";
import { L } from "./B01_Dashboard_UI_Common";
import {
badge,
dataTable,
emptyState,
nameCell,
pageFrame,
rowMenu,
type Cell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
const COLUMNS: Column[] = [
{ label: L("DE_New_Plan") },
{ label: L("DE_Assigned_Year"), width: "90px" },
{ label: L("D04_Col_Confirmed"), width: "150px" },
{ label: L("DE_Assigned_State"), width: "220px" },
{ label: "", width: "44px" },
];
function stateCell(plan: AssignedPlan): HTMLElement {
const cell = document.createElement("span");
cell.className = "b01-pg-inline";
cell.append(
plan.linked_project_id
? badge(L("DE_Assigned_Linked"), "success")
: badge(L("DE_Assigned_Unlinked"), "neutral"),
);
if (plan.update_available) cell.append(badge(L("DE_Assigned_Update"), "warning"));
return cell;
}
function planRow(plan: AssignedPlan): Cell[] {
const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE);
return [
nameCell(plan.project_name),
String(plan.year),
kstText(plan.confirmed_at),
stateCell(plan),
rowMenu([
{ label: L("DE_Assigned_View"), onClick: view },
{
label: L("DE_Assigned_Link"),
onClick: () =>
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
},
]),
];
}
export async function mountAssigned(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const page = pageFrame(
host,
L("DE_Assigned_Title"),
"산림청이 지정한 예정노선이 여기 모임 · 연결하면 임도 설계가 그 노선으로 시작됨",
);
const plans = await fetchAssignedPlans().catch(() => []);
if (!host.isConnected) return;
page.body.append(
plans.length
? dataTable(COLUMNS, plans.map(planRow))
: emptyState({
icon: "route",
text: "아직 받은 예정노선이 없음",
hint: "산림청이 우리 회사에 예정노선을 지정하면 여기에 나타남",
onGuide: () => ctx.go("guide"),
}),
);
}
@@ -0,0 +1,107 @@
/* =============================================================================
* B01_Dashboard_UI_View_Company.ts
* 세부 페이지 「회사 정보」(549-1) — 설정 줄 카드(이름 · 사업자번호 · 대표자 · 주소 · 상태 · 수 · 로고).
* 회사 관리자 · 시스템 관리자 = [회사 정보 수정] · 로고 고르기 / 일반 · 산림청 = 보기만
* 시스템 관리자 = 머리에서 고른 회사 · 회사 없음 = [회사 생성] [회사 검색]
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import {
fetchAllCompanies,
fetchCompanyAssets,
fetchUserCompany,
setCompanyLogo,
type CompanyInfo,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { L } from "./B01_Dashboard_UI_Common";
import {
openCreateCompanyModal,
openEditCompanyModal,
openFindCompanyModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
emptyState,
pageFrame,
primaryButton,
settingCard,
settingRow,
valueText,
} from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
async function loadCompany(ctx: ViewCtx): Promise<CompanyInfo | null> {
if (ctx.role === "SYSTEM_ADMIN") {
const all = await fetchAllCompanies().catch(() => []);
return all.find((c) => c.id === ctx.companyId) ?? null;
}
return fetchUserCompany().catch(() => null);
}
/** 로고 줄 — 관리자는 고르기 · 그 밖은 등록 여부만 */
function logoRow(company: CompanyInfo, ctx: ViewCtx, canEdit: boolean): HTMLElement {
const desc = "도면 표제란에 들어감 · 프로젝트가 따로 고르지 않으면 이 로고를 씀";
if (!canEdit) {
return settingRow("회사 로고", desc, valueText(company.logo_asset_id ? "등록됨" : null));
}
const slot = document.createElement("div");
void fetchCompanyAssets(company.id).then((assets) => {
slot.append(
createAssetField("", "LOGO", assets, company.logo_asset_id ?? null, company.id, ctx.me, {
onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined),
}).root,
);
});
return settingRow("회사 로고", desc, slot);
}
export async function mountCompany(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const page = pageFrame(host, "회사 정보", "회사 이름 · 사업자 정보 · 도면에 들어가는 로고");
const company = await loadCompany(ctx);
if (!host.isConnected) return;
if (!company) {
const actions = document.createElement("div");
actions.className = "b01-pg-inline";
actions.append(
primaryButton(L("B01_Dashboard_CreateCompany"), () => openCreateCompanyModal()),
createButton({
label: L("B01_Dashboard_FindCompany"),
variant: "ghost",
onClick: () => openFindCompanyModal(),
}),
);
page.body.append(
emptyState({
icon: "building",
text: "소속 회사가 없음",
hint: "회사를 만들거나 이미 있는 회사를 찾아 가입 요청을 보낼 수 있음",
action: actions,
onGuide: () => ctx.go("guide"),
}),
);
return;
}
// 회사 정보 수정 · 로고 = 관리자만(범위는 백엔드가 다시 봄)
const canEdit = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN";
if (canEdit)
page.setAction(primaryButton("회사 정보 수정", () => openEditCompanyModal(company), "pencil"));
const status = company.business_status ? statusBadgeOf(company.business_status) : null;
page.body.append(
settingCard([
settingRow("회사 이름", "", valueText(company.name)),
settingRow(
L("B01_Dashboard_Field_BusinessNumber"),
"",
valueText(company.business_registration_number),
),
settingRow(L("B01_Dashboard_Field_Owner"), "", valueText(company.business_owner)),
settingRow(L("B01_Dashboard_Field_Address"), "", valueText(company.business_address)),
settingRow("사업자 상태", "", status ? badge(status.text, status.tone) : valueText(null)),
settingRow(L("B01_Dashboard_Metric_ActiveUsers"), "", valueText(company.user_count ?? 0)),
settingRow(L("B01_Dashboard_Projects"), "", valueText(company.project_count ?? 0)),
logoRow(company, ctx, canEdit),
]),
);
}
@@ -0,0 +1,37 @@
/* =============================================================================
* B01_Dashboard_UI_View_CompanyDefaults.ts
* 세부 페이지 「회사 설계 기본값」(549-1) — 구조물 조합 회사 층 등 M02 로 가는 설정 줄.
* 줄 규칙 = `settingLinks` 한 벌(역할로 카드 숨김 · 회사 관리자만 수정 · 그 밖은 읽기)
* ========================================================================== */
import { navigateTo } from "../A00_Common/router";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { createButton } from "@ui/ui_template_elements";
import { emptyState, pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
export function mountCompanyDefaults(host: HTMLElement, ctx: ViewCtx): void {
const page = pageFrame(
host,
"회사 설계 기본값",
"새 프로젝트가 처음 받는 구조물 조합 · 회사 층은 회사 관리자가 고침",
);
const links = settingLinks(ctx.me, ctx.companyId);
page.body.append(
links.length
? settingCard(
links.map((link) =>
settingRow(
link.title,
link.desc,
createButton({
label: ctx.role === "ADMIN" ? "열기 · 수정" : "열기",
variant: "ghost",
onClick: () => navigateTo(link.route, link.query),
}),
),
),
)
: emptyState({ icon: "adjustments", text: "표시할 설정이 없음" }),
);
}
@@ -0,0 +1,247 @@
/* =============================================================================
* B01_Dashboard_UI_View_People.ts
* 세부 페이지 「사람」(549-1) — 탭 셋: 팀원 · 가입 요청(수 배지) · 초대.
* 회사 관리자 = 자기 회사 · 시스템 관리자 = 머리에서 고른 회사
* 팀원 줄 ⋯ = 수정 · 역할 변경 · 회사에서 빼기(빨강) — 확인은 그 모달이 맡음
* 가입 요청 줄 = [승인] [거절] · 초대 = 이메일로 안내 메일 · 소속 없는 가입자는 [팀원 추가]
* ========================================================================== */
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import {
fetchAllUsers,
fetchCompanyJoinRequests,
fetchCompanyMembers,
inviteMember,
type DashboardUser,
type JoinRequest,
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { formatDate, L } from "./B01_Dashboard_UI_Common";
import { onB01_JoinRequest_Process_Click } from "./B01_Dashboard_UI_Company";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openAddMemberModal,
openChangeRoleModal,
openDeleteUserModal,
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import {
badge,
dataTable,
emptyState,
kitIcon,
pageFrame,
primaryButton,
rowMenu,
tabBar,
type Cell,
type Column,
} from "./B01_Dashboard_UI_PageKit";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
import { pendingCount, roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
type TabKey = "members" | "requests" | "invite";
/** 승인 · 거절 뒤 페이지가 다시 그려져도 보던 탭을 유지 */
let lastTab: TabKey = "members";
const MEMBER_COLUMNS: Column[] = [
{ label: "이름" },
{ label: "이메일" },
{ label: "직급 · 부서" },
{ label: "역할", width: "120px" },
{ label: "상태", width: "90px" },
{ label: "", width: "44px" },
];
const REQUEST_COLUMNS: Column[] = [
{ label: "요청자" },
{ label: "이메일" },
{ label: "요청일", width: "110px" },
{ label: "상태", width: "90px" },
{ label: "", width: "150px", align: "end" },
];
function person(name: string): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-pg-person";
const avatar = document.createElement("span");
avatar.className = "b01-pg-avatar";
avatar.textContent = (name.trim() || "?").slice(0, 1);
const text = document.createElement("span");
text.className = "b01-pg-name__main";
text.textContent = name || "-";
wrap.append(avatar, text);
return wrap;
}
function memberRow(m: Member, ctx: ViewCtx): Cell[] {
const me = ctx.me;
const status = statusBadgeOf(m.status);
const items = [{ label: L("Common_Btn_Edit"), onClick: () => openEditUserModal(me, m) }];
const rows = [...items];
if (canChangeRole(me, m)) {
rows.push({ label: L("B01_Dashboard_ChangeRole"), onClick: () => openChangeRoleModal(m) });
}
const system = ctx.role === "SYSTEM_ADMIN";
const removable = canDeleteUser(me, m) && (!system || m.id !== me.id);
return [
person(m.name),
m.email,
[m.position, m.department].filter(Boolean).join(" · ") || "-",
badge(roleText(m.role), m.role === "ADMIN" || m.role === "SYSTEM_ADMIN" ? "accent" : "neutral"),
badge(status.text, status.tone),
rowMenu([
...rows,
...(removable
? [
{
label: L(system ? "B01_Dashboard_DeleteUser" : "B01_Dashboard_RemoveMember"),
danger: true,
ownConfirm: true,
onClick: () => openDeleteUserModal(m),
},
]
: []),
]),
];
}
function requestRow(r: JoinRequest, system: boolean): Cell[] {
const status = statusBadgeOf(r.status);
const waiting = !r.status || r.status.toUpperCase() === "PENDING";
const actions = document.createElement("span");
actions.className = "b01-pg-inline";
if (waiting) {
actions.append(
createButton({
label: L("B01_Dashboard_Approve"),
size: "sm",
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "APPROVE", system),
}),
createButton({
label: L("B01_Dashboard_Reject"),
size: "sm",
variant: "danger",
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "REJECT", system),
}),
);
}
return [
person(r.user_name ?? r.user_email),
r.user_email,
formatDate(r.requested_at),
badge(status.text, status.tone),
actions,
];
}
/** 초대 탭 — 아직 가입 전인 사람에게 안내 메일 · 이미 가입한 사람은 [팀원 추가] */
function invitePanel(companyId: number | null | undefined, onAdd: () => void): HTMLElement {
const box = document.createElement("div");
box.className = "b01-pg-card b01-pg-invite";
const email = createInputField({
label: "초대할 이메일",
type: "email",
placeholder: "name@company.com",
});
const send = createButton({
label: "초대 메일 보내기",
iconStart: kitIcon("mail"),
onClick: async () => {
const value = email.input.value.trim();
if (!value.includes("@")) {
email.setError("메일을 보낼 이메일 주소를 입력");
return;
}
email.setError();
try {
await inviteMember(value, null, companyId);
email.input.value = "";
showToast("안내 메일을 보냈음", "success");
} catch (error) {
showToast(
error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"),
"error",
);
}
},
});
const form = document.createElement("div");
form.className = "b01-pg-invite__form";
form.append(email.root, send);
const note = document.createElement("p");
note.className = "b01-pg-note";
note.textContent = "아직 가입하지 않은 사람에게 가입 안내 메일만 보냄 · 계정은 본인이 만듦";
const already = document.createElement("p");
already.className = "b01-pg-note";
already.append("이미 가입했고 소속이 없는 사람은 ");
const link = document.createElement("button");
link.type = "button";
link.className = "b01-pg-link";
link.textContent = "팀원 추가";
link.addEventListener("click", onAdd);
already.append(link, " 에서 바로 등록");
box.append(form, note, already);
return box;
}
async function loadPeople(ctx: ViewCtx): Promise<{ members: Member[]; requests: JoinRequest[] }> {
const system = ctx.role === "SYSTEM_ADMIN";
const [members, requests] = await Promise.all([
(system ? fetchAllUsers(ctx.companyId) : fetchCompanyMembers()).catch(() => [] as Member[]),
fetchCompanyJoinRequests(system ? ctx.companyId : undefined).catch(() => []),
]);
return { members: members as DashboardUser[], requests };
}
export async function mountPeople(host: HTMLElement, ctx: ViewCtx): Promise<void> {
const system = ctx.role === "SYSTEM_ADMIN";
const companyId = system ? ctx.companyId : undefined;
const page = pageFrame(host, "사람", "회사 팀원 · 가입 요청 · 초대를 한곳에서 관리");
const addMember = (): void => openAddMemberModal(undefined, companyId);
page.setAction(primaryButton("팀원 추가", addMember));
const { members, requests } = await loadPeople(ctx);
if (!host.isConnected) return;
const content = document.createElement("div");
const draw = (key: TabKey): void => {
if (key === "members") {
content.replaceChildren(
members.length
? dataTable(
MEMBER_COLUMNS,
members.map((m) => memberRow(m, ctx)),
)
: emptyState({
icon: "users",
text: "등록된 팀원이 없음",
action: primaryButton("팀원 추가", addMember),
onGuide: () => ctx.go("guide"),
}),
);
} else if (key === "requests") {
content.replaceChildren(
requests.length
? dataTable(
REQUEST_COLUMNS,
requests.map((r) => requestRow(r, system)),
)
: emptyState({ icon: "users", text: "대기 중인 가입 요청이 없음" }),
);
} else {
content.replaceChildren(invitePanel(companyId, addMember));
}
};
const tabs = tabBar(
[
{ key: "members", label: "팀원" },
{ key: "requests", label: "가입 요청" },
{ key: "invite", label: "초대" },
],
lastTab,
(key) => draw((lastTab = key as TabKey)),
);
tabs.setBadge("requests", pendingCount(requests));
page.body.append(tabs.root, content);
draw(lastTab);
}
@@ -0,0 +1,20 @@
/* =============================================================================
* B01_Dashboard_UI_View_Temp.ts
* 세부 페이지 「임시 보관함」(549-1) — 머리 + 오른쪽 위 [자료 올리기] + 보관함 본체(`UI_TempUpload`).
* ========================================================================== */
import { L } from "./B01_Dashboard_UI_Common";
import { pageFrame, primaryButton } from "./B01_Dashboard_UI_PageKit";
import { buildTempBox } from "./B01_Dashboard_UI_TempUpload";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
export function mountTemp(host: HTMLElement, ctx: ViewCtx): void {
const page = pageFrame(
host,
L("B01_Temp_Section"),
"프로젝트를 만들기 전에 측량 파일을 먼저 올려 두는 곳 · 새 프로젝트를 만들 때 불러 씀",
);
const box = buildTempBox(() => ctx.go("guide"));
page.setAction(primaryButton("자료 올리기", box.openCreate, "upload"));
page.body.append(box.root);
}
+10 -5
View File
@@ -12,6 +12,11 @@ import type { IconName } from "@ui/ui_template_icons";
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
import { mountAssigned } from "./B01_Dashboard_UI_View_Assigned";
import { mountCompany } from "./B01_Dashboard_UI_View_Company";
import { mountCompanyDefaults } from "./B01_Dashboard_UI_View_CompanyDefaults";
import { mountPeople } from "./B01_Dashboard_UI_View_People";
import { mountTemp } from "./B01_Dashboard_UI_View_Temp";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
@@ -49,12 +54,12 @@ export const VIEWS: View[] = [
{ key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") },
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") },
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: legacy("assigned") },
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: legacy("temp") },
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned },
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: mountTemp },
// 회사 관리
{ key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: legacy("people") },
{ 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: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople },
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany },
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: mountCompanyDefaults },
// 시스템 관리
{ 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") },
+10 -7
View File
@@ -24,13 +24,16 @@ def _text(path: Path) -> str:
def _run_views(body: str) -> object:
src = re.sub(r"^import[\s\S]*?;\n", "", _text(B01 / "B01_Dashboard_UI_Views.ts"), flags=re.M)
script = (
"const mountLegacy = () => {}; const mountGuide = () => {}; const mountChat = () => {};\n"
+ src
+ "\n"
+ body
)
raw = _text(B01 / "B01_Dashboard_UI_Views.ts")
src = re.sub(r"^import[\s\S]*?;\n", "", raw, flags=re.M)
# 값으로 가져오는 이름(mount 함수들)은 빈 함수로 — 타입 가져오기는 건너뜀
names = [
n.split(" as ")[-1].strip()
for m in re.finditer(r"^import\s+\{([^}]*)\}\s+from[^;]*;", raw, flags=re.M)
for n in m.group(1).split(",")
if n.strip() and not n.strip().startswith("type ")
]
script = "".join(f"const {n} = () => {{}}; " for n in names) + "\n" + src + "\n" + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
+114
View File
@@ -0,0 +1,114 @@
"""549-1 대시보드 세부 페이지(받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값) 시험.
· 화면 계산(`logic/B01_Dashboard_Logic_Pages.ts`) — 역할 · 상태 한글 · 남은 날 · 막대 · 가입 요청 수 — Node 로 바로 돌림
· 등록부 — 네 몫 다섯 열쇠가 옛 구역(legacy)이 아닌 새 페이지를 가리킴 · 역할 값 그대로
· 화면 조작 · 눌러 보기는 ORCA
"""
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"
LOGIC = B01 / "logic" / "B01_Dashboard_Logic_Pages.ts"
MINE = {
"assigned": "mountAssigned",
"temp": "mountTemp",
"people": "mountPeople",
"company": "mountCompany",
"company-defaults": "mountCompanyDefaults",
}
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _run_logic(body: str) -> object:
script = LOGIC.read_text(encoding="utf-8") + "\n" + body
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
def test_역할_상태_한글_배지():
out = _run_logic(
"console.log(JSON.stringify(["
"roleText('SYSTEM_ADMIN'), roleText('ADMIN'), roleText('USER'), roleText(null),"
"statusBadgeOf('ACTIVE'), statusBadgeOf('inactive'), statusBadgeOf('PENDING'),"
"statusBadgeOf('REJECTED'), statusBadgeOf('뭔가'), statusBadgeOf(null)]));"
)
assert out[:4] == ["시스템 관리자", "회사 관리자", "일반 사용자", "-"]
assert out[4] == {"text": "활성", "tone": "success"}
assert out[5] == {"text": "비활성", "tone": "neutral"}
assert out[6] == {"text": "대기", "tone": "warning"}
assert out[7] == {"text": "거절", "tone": "danger"}
assert out[8] == {"text": "뭔가", "tone": "neutral"}
assert out[9] == {"text": "-", "tone": "neutral"}
@needs_node
def test_보관함_남은_날_막대_강조():
out = _run_logic(
"const now = Date.parse('2026-10-26T00:00:00Z');"
"const d = (s: string | null) => daysLeft(s, now);"
"console.log(JSON.stringify(["
"d('2026-11-25T00:00:00Z'), d('2026-11-02T00:00:00Z'), d('2026-11-01T12:00:00Z'),"
"d('2026-10-20T00:00:00Z'), d(null), d('아무말'),"
"leftPercent(30, 30), leftPercent(15, 30), leftPercent(0, 30), leftPercent(null, 30),"
"leftPercent(40, 30), leftPercent(5, 0),"
"isExpiringSoon(7), isExpiringSoon(8), isExpiringSoon(0), isExpiringSoon(null)]));"
)
# 30 일 · 7 일 · 올림(6.5 → 7) · 지남 0 · 없음 · 잘못된 값
assert out[:6] == [30, 7, 7, 0, None, None]
assert out[6:12] == [100, 50, 0, 0, 100, 0]
assert out[12:] == [True, False, True, False]
@needs_node
def test_가입_요청_수는_대기만():
out = _run_logic(
"console.log(JSON.stringify(["
"pendingCount([]), pendingCount([{status:'PENDING'},{status:'pending'},{}]),"
"pendingCount([{status:'APPROVED'},{status:'REJECTED'}])]));"
)
assert out == [0, 3, 0]
@needs_node
def test_바이트_글():
out = _run_logic(
"console.log(JSON.stringify([bytesText(100), bytesText(2048), bytesText(5*1024*1024),"
"bytesText(3*1024*1024*1024)]));"
)
assert out == ["1 KB", "2 KB", "5.0 MB", "3.00 GB"]
def test_등록부_네_몫_다섯_열쇠는_새_페이지():
text = (B01 / "B01_Dashboard_UI_Views.ts").read_text(encoding="utf-8")
for key, mount in MINE.items():
line = next(ln for ln in text.splitlines() if f'key: "{key}"' in ln)
assert f"mount: {mount}" in line, key
assert "legacy(" not in line, key
assert f"import {{ {mount} }}" in text, key
# 역할 값은 546 그대로
assert re.search(r'key: "assigned".*roles: \["USER", "ADMIN", "SYSTEM_ADMIN"\]', text)
assert re.search(r'key: "people".*roles: \["ADMIN", "SYSTEM_ADMIN"\]', text)
assert re.search(r'key: "company-defaults".*roles: \["USER", "ADMIN", "FOREST"\]', text)
def test_새_페이지_파일은_700줄_이하():
files = [*B01.glob("B01_Dashboard_UI_View_*.ts"), B01 / "B01_Dashboard_UI_PageKit.ts",
B01 / "B01_Dashboard_UI_TempUpload.ts", LOGIC] # fmt: skip
for path in files:
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name