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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 사용자 확정).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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") },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user