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
|
* B01_Dashboard_UI_AssignedPlans.ts
|
||||||
* 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
|
* 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
|
||||||
* 열 = 예정노선 · 연도 · 확정(KST) · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결]
|
* 화면 = `B01_Dashboard_UI_View_Assigned.ts` · [연결] 모달 = `B01_Dashboard_UI_PlanLink`
|
||||||
* [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 모달(`B01_Dashboard_UI_PlanLink`)
|
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
import { API_BASE_URL } 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";
|
|
||||||
|
|
||||||
export interface AssignedPlan {
|
export interface AssignedPlan {
|
||||||
assignee_id: number;
|
assignee_id: number;
|
||||||
@@ -32,54 +25,3 @@ export async function fetchAssignedPlans(): Promise<AssignedPlan[]> {
|
|||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? [];
|
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 {
|
import {
|
||||||
fetchCompanyAssets,
|
|
||||||
processJoinRequest,
|
processJoinRequest,
|
||||||
setCompanyLogo,
|
setCompanyLogo,
|
||||||
type CompanyInfo,
|
type CompanyInfo,
|
||||||
type DashboardUser,
|
type DashboardUser,
|
||||||
type JoinRequest,
|
|
||||||
type Member,
|
|
||||||
} from "./B01_Dashboard_Api_Fetch";
|
} from "./B01_Dashboard_Api_Fetch";
|
||||||
import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
||||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
import { openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
|
||||||
import {
|
import { table, text } from "@ui/ui_template_general_blocks";
|
||||||
openChangeRoleModal,
|
import { DASHBOARD_VISIBLE_ROWS, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
|
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
|
||||||
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
|
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||||
|
|||||||
@@ -14,55 +14,36 @@ import {
|
|||||||
fetchAllUsers,
|
fetchAllUsers,
|
||||||
fetchAuditLogs,
|
fetchAuditLogs,
|
||||||
fetchCompanyJoinRequests,
|
fetchCompanyJoinRequests,
|
||||||
fetchCompanyMembers,
|
|
||||||
fetchCompanyProjects,
|
fetchCompanyProjects,
|
||||||
fetchSystemResources,
|
fetchSystemResources,
|
||||||
fetchUserCompany,
|
|
||||||
fetchUserProjects,
|
fetchUserProjects,
|
||||||
type AuditLog,
|
type AuditLog,
|
||||||
type CompanyInfo,
|
type CompanyInfo,
|
||||||
type DashboardUser,
|
type DashboardUser,
|
||||||
type JoinRequest,
|
type JoinRequest,
|
||||||
type Member,
|
|
||||||
type ProjectItem,
|
type ProjectItem,
|
||||||
type ResourceData,
|
type ResourceData,
|
||||||
} from "./B01_Dashboard_Api_Fetch";
|
} from "./B01_Dashboard_Api_Fetch";
|
||||||
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
|
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 { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
|
||||||
import { L } from "./B01_Dashboard_UI_Common";
|
import { L } from "./B01_Dashboard_UI_Common";
|
||||||
import {
|
import { companyTable } from "./B01_Dashboard_UI_Company";
|
||||||
buildCompanyPanel,
|
|
||||||
companyTable,
|
|
||||||
joinRequestTable,
|
|
||||||
memberTable,
|
|
||||||
} from "./B01_Dashboard_UI_Company";
|
|
||||||
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
|
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
|
||||||
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
|
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
|
||||||
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
|
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
|
||||||
import { projectTable } from "./B01_Dashboard_UI_Projects";
|
import { projectTable } from "./B01_Dashboard_UI_Projects";
|
||||||
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
|
||||||
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
|
|
||||||
|
|
||||||
export interface DashboardState {
|
export interface DashboardState {
|
||||||
user: DashboardUser;
|
user: DashboardUser;
|
||||||
userProjects: ProjectItem[];
|
userProjects: ProjectItem[];
|
||||||
companyProjects: ProjectItem[];
|
companyProjects: ProjectItem[];
|
||||||
allProjects: ProjectItem[];
|
allProjects: ProjectItem[];
|
||||||
company: CompanyInfo | null;
|
|
||||||
members: Member[];
|
|
||||||
joinRequests: JoinRequest[];
|
|
||||||
allCompanies: CompanyInfo[];
|
allCompanies: CompanyInfo[];
|
||||||
allUsers: DashboardUser[];
|
allUsers: DashboardUser[];
|
||||||
allJoinRequests: JoinRequest[];
|
allJoinRequests: JoinRequest[];
|
||||||
auditLogs: AuditLog[];
|
auditLogs: AuditLog[];
|
||||||
resources: ResourceData | null;
|
resources: ResourceData | null;
|
||||||
/** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */
|
|
||||||
assignedPlans: AssignedPlan[];
|
|
||||||
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
|
/** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */
|
||||||
adminCompanyId: number | null;
|
adminCompanyId: number | null;
|
||||||
}
|
}
|
||||||
@@ -103,23 +84,9 @@ async function loadRoleData(state: DashboardState): Promise<void> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [projects, company, assigned] = await Promise.all([
|
state.userProjects = await fetchUserProjects().catch(() => []);
|
||||||
fetchUserProjects().catch(() => []),
|
|
||||||
fetchUserCompany().catch(() => null),
|
|
||||||
fetchAssignedPlans().catch(() => []),
|
|
||||||
]);
|
|
||||||
state.userProjects = projects;
|
|
||||||
state.company = company;
|
|
||||||
state.assignedPlans = assigned;
|
|
||||||
if (state.user.role === "ADMIN") {
|
if (state.user.role === "ADMIN") {
|
||||||
const [members, requests, companyProjects] = await Promise.all([
|
state.companyProjects = await fetchCompanyProjects().catch(() => []);
|
||||||
fetchCompanyMembers().catch(() => []),
|
|
||||||
fetchCompanyJoinRequests().catch(() => []),
|
|
||||||
fetchCompanyProjects().catch(() => []),
|
|
||||||
]);
|
|
||||||
state.members = members;
|
|
||||||
state.joinRequests = requests;
|
|
||||||
state.companyProjects = companyProjects;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) => [
|
sysOverview: (s) => [
|
||||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true),
|
section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true),
|
||||||
],
|
],
|
||||||
@@ -261,15 +186,11 @@ export async function mountLegacy(
|
|||||||
userProjects: [],
|
userProjects: [],
|
||||||
companyProjects: [],
|
companyProjects: [],
|
||||||
allProjects: [],
|
allProjects: [],
|
||||||
company: null,
|
|
||||||
members: [],
|
|
||||||
joinRequests: [],
|
|
||||||
allCompanies: [],
|
allCompanies: [],
|
||||||
allUsers: [],
|
allUsers: [],
|
||||||
allJoinRequests: [],
|
allJoinRequests: [],
|
||||||
auditLogs: [],
|
auditLogs: [],
|
||||||
resources: null,
|
resources: null,
|
||||||
assignedPlans: [],
|
|
||||||
adminCompanyId: null,
|
adminCompanyId: null,
|
||||||
};
|
};
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import "./B01_Dashboard_UI_Style_Pages.css";
|
|||||||
const KIT_PATHS = {
|
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",
|
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",
|
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",
|
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",
|
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:
|
inbox:
|
||||||
@@ -91,6 +92,8 @@ export interface RowMenuItem {
|
|||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
/** 확인창 글(danger 일 때) */
|
/** 확인창 글(danger 일 때) */
|
||||||
confirm?: string;
|
confirm?: string;
|
||||||
|
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
|
||||||
|
ownConfirm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
|
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
|
||||||
@@ -117,7 +120,7 @@ export function rowMenu(items: RowMenuItem[]): HTMLElement {
|
|||||||
b.setAttribute("role", "menuitem");
|
b.setAttribute("role", "menuitem");
|
||||||
b.addEventListener("click", () => {
|
b.addEventListener("click", () => {
|
||||||
setOpen(false);
|
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(
|
void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then(
|
||||||
(ok) => ok && item.onClick(),
|
(ok) => ok && item.onClick(),
|
||||||
);
|
);
|
||||||
@@ -126,7 +129,7 @@ export function rowMenu(items: RowMenuItem[]): HTMLElement {
|
|||||||
}
|
}
|
||||||
trigger.addEventListener("click", (e) => {
|
trigger.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(pop.hidden);
|
setOpen(pop.hidden === true);
|
||||||
});
|
});
|
||||||
const outside = (e: MouseEvent): void => {
|
const outside = (e: MouseEvent): void => {
|
||||||
if (!wrap.isConnected) document.removeEventListener("click", outside);
|
if (!wrap.isConnected) document.removeEventListener("click", outside);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.b01-pg-table tr.is-sub td {
|
.b01-pg-table tr.is-sub > td {
|
||||||
height: auto;
|
height: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--b01-bg);
|
background: var(--b01-bg);
|
||||||
@@ -254,6 +254,9 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
max-width: 55%;
|
max-width: 55%;
|
||||||
}
|
}
|
||||||
|
.b01-pg-row__side .ui-field__label:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.b01-pg-row__value {
|
.b01-pg-row__value {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -340,10 +343,11 @@
|
|||||||
background: none;
|
background: none;
|
||||||
color: var(--b01-muted);
|
color: var(--b01-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transform: rotate(-90deg);
|
||||||
transition: transform 0.12s;
|
transition: transform 0.12s;
|
||||||
}
|
}
|
||||||
.b01-pg-caret.is-open {
|
.b01-pg-caret.is-open {
|
||||||
transform: rotate(90deg);
|
transform: none;
|
||||||
}
|
}
|
||||||
.b01-pg-files {
|
.b01-pg-files {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -422,3 +426,32 @@
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--b01-muted);
|
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
|
* B01_Dashboard_UI_Style_Temp.css
|
||||||
* 임시 보관함 섹션 — 그룹(임시 프로젝트명) + 하위 파일 표, 등록 모달
|
* 임시 보관함 — 등록 · 파일 추가 모달 조각(목록 표는 `B01_Dashboard_UI_Style_Pages.css`).
|
||||||
* 표·모달 껍데기는 대시보드 공통 스타일을 그대로 쓰고, 여기서는 그룹 카드와
|
|
||||||
* 진행률 막대만 정의한다. 색·간격은 theme.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 {
|
.b01-temp__hidden-input {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||||
import { table, text } from "@ui/ui_template_general_blocks";
|
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";
|
import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
|
||||||
|
|
||||||
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
|
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
|
||||||
@@ -20,13 +21,7 @@ export function tempFileType(fileName: string): string {
|
|||||||
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
|
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTempBytes(bytes: number): string {
|
export const formatTempBytes = bytesText;
|
||||||
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 interface TempModalOptions {
|
export interface TempModalOptions {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B01_Dashboard_UI_TempUpload.ts
|
* B01_Dashboard_UI_TempUpload.ts
|
||||||
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
* 임시 보관함 본체(549-1) — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
||||||
*
|
* 목록 = 묶음 표(이름 · 상태 · 남은 날 막대 · 올린 날 · 줄 끝 ⋯) · 줄을 펼치면 그 안 파일 · 진행률
|
||||||
* 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시):
|
* ⋯ = 파일 추가 · 삭제(확인) · 파일 줄 ⋯ = 삭제(확인) · 삭제하면 임시 저장소 실제 파일도 지움
|
||||||
* - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환
|
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있음
|
||||||
* - 그룹(임시 프로젝트명) 아래에 파일 리스트(표)
|
* 화면 틀(머리 · 주 단추)은 `B01_Dashboard_UI_View_Temp.ts`
|
||||||
* - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음)
|
|
||||||
* - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다
|
|
||||||
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
|
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { limitVisibleRows, section, table, text } from "@ui/ui_template_general_blocks";
|
import { icon } from "@ui/ui_template_icons";
|
||||||
|
import {
|
||||||
|
bytesText,
|
||||||
|
daysLeft,
|
||||||
|
EXPIRY_WARN_DAYS,
|
||||||
|
isExpiringSoon,
|
||||||
|
leftPercent,
|
||||||
|
} from "./logic/B01_Dashboard_Logic_Pages";
|
||||||
import {
|
import {
|
||||||
createTempBatch,
|
createTempBatch,
|
||||||
createTempUploadSession,
|
createTempUploadSession,
|
||||||
@@ -25,8 +29,18 @@ import {
|
|||||||
uploadTempChunk,
|
uploadTempChunk,
|
||||||
type TempBatchItem,
|
type TempBatchItem,
|
||||||
} from "./B01_Dashboard_Api_Temp";
|
} from "./B01_Dashboard_Api_Temp";
|
||||||
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
|
import { L } from "./B01_Dashboard_UI_Common";
|
||||||
import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
|
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";
|
import "./B01_Dashboard_UI_Style_Temp.css";
|
||||||
|
|
||||||
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
||||||
@@ -65,40 +79,48 @@ function formatDate(value: string | null): string {
|
|||||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
|
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const COLUMNS: Column[] = [
|
||||||
* 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다.
|
{ label: "보관 이름" },
|
||||||
* 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음).
|
{ label: "상태", width: "120px" },
|
||||||
*/
|
{ label: "남은 기간", width: "210px" },
|
||||||
export function buildTempUploadSection(): HTMLElement {
|
{ label: "올린 날", width: "110px" },
|
||||||
const panel = document.createElement("div");
|
{ label: "", width: "44px" },
|
||||||
panel.className = "b01-temp";
|
];
|
||||||
|
|
||||||
// 보관 기간은 제목 옆 태그로 알린다(서버가 준 값으로 목록 조회 때 갱신).
|
export interface TempBox {
|
||||||
const retentionTag = createTag(`${30}${L("B01_Temp_Hint_Days")}`, "neutral");
|
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");
|
const listHost = document.createElement("div");
|
||||||
listHost.className = "b01-temp__list";
|
root.append(note, listHost);
|
||||||
panel.append(listHost);
|
|
||||||
|
|
||||||
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
|
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
|
||||||
const tasks = new Map<string, UploadTask[]>();
|
const tasks = new Map<string, UploadTask[]>();
|
||||||
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
|
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
|
||||||
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
|
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
|
||||||
/** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */
|
/** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */
|
||||||
const openGroups = new Set<string>();
|
const openGroups = new Set<string>();
|
||||||
/** 4개 높이 제한을 다시 재는 함수 — 그룹을 펼치면 높이가 달라져 다시 재야 한다. */
|
let retentionDays = 30;
|
||||||
let remeasureGroups: (() => void) | null = null;
|
let lastBatches: TempBatchItem[] = [];
|
||||||
|
|
||||||
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
|
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function progressCell(key: string, percent: number, label: string): HTMLElement {
|
function progressCell(key: string, percent: number, label: string): HTMLElement {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("span");
|
||||||
wrap.className = "b01-temp__progress";
|
wrap.className = "b01-pg-progress";
|
||||||
const caption = document.createElement("span");
|
const caption = document.createElement("span");
|
||||||
caption.textContent = `${label} ${Math.round(percent)}%`;
|
caption.textContent = `${label} ${Math.round(percent)}%`;
|
||||||
const bar = document.createElement("div");
|
const bar = document.createElement("span");
|
||||||
bar.className = "b01-temp__progress-bar";
|
bar.className = "b01-pg-progress__bar";
|
||||||
const fill = document.createElement("div");
|
const fill = document.createElement("i");
|
||||||
fill.style.width = `${percent}%`;
|
fill.style.width = `${percent}%`;
|
||||||
bar.append(fill);
|
bar.append(fill);
|
||||||
wrap.append(caption, bar);
|
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)}%`;
|
node.caption.textContent = `${L("B01_Temp_File_Status_Uploading")} ${Math.round(task.percent)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileRows(batch: TempBatchItem): HTMLElement[][] {
|
function fileLine(cells: (string | Node)[]): HTMLElement {
|
||||||
const rows: HTMLElement[][] = [];
|
const tr = document.createElement("tr");
|
||||||
|
for (const cell of cells) {
|
||||||
for (const file of batch.files) {
|
const td = document.createElement("td");
|
||||||
// 목록에는 아직 프로젝트에 안 쓴 묶음만 온다 — 항상 지울 수 있다.
|
td.append(cell);
|
||||||
const action = createButton({
|
tr.append(td);
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
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) ?? [];
|
const running = tasks.get(batch.batch_id) ?? [];
|
||||||
for (const task of running) {
|
for (const task of running) {
|
||||||
const status =
|
const status =
|
||||||
task.status === "failed"
|
task.status === "failed"
|
||||||
? text(L("B01_Temp_File_Status_Failed"))
|
? badge(L("B01_Temp_File_Status_Failed"), "danger")
|
||||||
: task.status === "waiting"
|
: task.status === "waiting"
|
||||||
? text(L("B01_Temp_File_Status_Waiting"))
|
? badge(L("B01_Temp_File_Status_Waiting"), "neutral")
|
||||||
: progressCell(
|
: progressCell(
|
||||||
`${batch.batch_id}::${task.key}`,
|
`${batch.batch_id}::${task.key}`,
|
||||||
task.percent,
|
task.percent,
|
||||||
L("B01_Temp_File_Status_Uploading"),
|
L("B01_Temp_File_Status_Uploading"),
|
||||||
);
|
);
|
||||||
rows.push([
|
body.append(
|
||||||
text(task.fileType.toUpperCase()),
|
fileLine([
|
||||||
text(task.fileName),
|
task.fileType.toUpperCase(),
|
||||||
text(formatTempBytes(task.sizeBytes)),
|
task.fileName,
|
||||||
status,
|
bytesText(task.sizeBytes),
|
||||||
text("-"),
|
status,
|
||||||
]);
|
"",
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다.
|
// 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다.
|
||||||
for (const pending of batch.pending_sessions) {
|
for (const pending of batch.pending_sessions) {
|
||||||
const known = running.some((task) => task.fileName === pending.original_filename);
|
if (running.some((task) => task.fileName === pending.original_filename)) continue;
|
||||||
if (known) continue;
|
body.append(
|
||||||
rows.push([
|
fileLine([
|
||||||
text(tempFileType(pending.original_filename).toUpperCase()),
|
tempFileType(pending.original_filename).toUpperCase(),
|
||||||
text(pending.original_filename),
|
pending.original_filename,
|
||||||
text(formatTempBytes(pending.file_size_bytes)),
|
bytesText(pending.file_size_bytes),
|
||||||
progressCell(
|
progressCell(
|
||||||
`${batch.batch_id}::pending::${pending.upload_session_id}`,
|
`${batch.batch_id}::pending::${pending.upload_session_id}`,
|
||||||
pending.progress_percent,
|
pending.progress_percent,
|
||||||
L("B01_Temp_File_Status_Paused"),
|
L("B01_Temp_File_Status_Paused"),
|
||||||
),
|
),
|
||||||
text("-"),
|
"",
|
||||||
]);
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return rows;
|
if (!body.children.length)
|
||||||
|
body.append(fileLine(["", "파일 없음 · 묶음 ⋯ 에서 파일 추가", "", "", ""]));
|
||||||
|
table.append(body);
|
||||||
|
return table;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroup(batch: TempBatchItem): HTMLElement {
|
/** 남은 날 칸 — 막대 + 글 · 7 일 안 = 빨강 */
|
||||||
const group = document.createElement("div");
|
function leftCell(batch: TempBatchItem): HTMLElement {
|
||||||
group.className = `b01-temp__group is-${batch.status}`;
|
const left = daysLeft(batch.expires_at);
|
||||||
|
const cell = document.createElement("span");
|
||||||
const opened = openGroups.has(batch.batch_id);
|
// 다 올리기 전에는 만료가 정해지지 않음 — 막대 없이 「-」
|
||||||
group.classList.toggle("is-open", opened);
|
if (left === null) {
|
||||||
|
cell.className = "b01-pg-meta";
|
||||||
const head = document.createElement("div");
|
cell.textContent = "-";
|
||||||
head.className = "b01-temp__group-head";
|
return cell;
|
||||||
|
|
||||||
// 제목 행 = 접기/펼치기 트리거. 이름·상태·요약이 한 줄에 들어간다.
|
|
||||||
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)}`);
|
|
||||||
}
|
}
|
||||||
meta.textContent = parts.join(" · ");
|
cell.className = `b01-pg-left${isExpiringSoon(left) ? " is-soon" : ""}`;
|
||||||
titleRow.append(caret, title, badge, meta);
|
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");
|
function nameWithCaret(batch: TempBatchItem, opened: boolean): HTMLElement {
|
||||||
actions.className = "b01-dashboard__actions";
|
const wrap = document.createElement("div");
|
||||||
// 버튼 클릭이 접기/펼치기까지 건드리지 않게 막는다.
|
wrap.className = "b01-pg-person";
|
||||||
actions.addEventListener("click", (event) => event.stopPropagation());
|
const caret = document.createElement("button");
|
||||||
actions.append(
|
caret.type = "button";
|
||||||
createButton({
|
caret.className = `b01-pg-caret${opened ? " is-open" : ""}`;
|
||||||
label: L("B01_Temp_Btn_Add"),
|
caret.title = caret.ariaLabel = "파일 펼치기";
|
||||||
variant: "ghost",
|
caret.setAttribute("aria-expanded", String(opened));
|
||||||
onClick: () => openAddModal(batch),
|
caret.append(icon("chevronDown", 14));
|
||||||
}),
|
caret.addEventListener("click", () => {
|
||||||
createButton({
|
if (!openGroups.delete(batch.batch_id)) openGroups.add(batch.batch_id);
|
||||||
label: L("Common_Btn_Delete"),
|
draw(lastBatches);
|
||||||
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?.();
|
|
||||||
});
|
});
|
||||||
|
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");
|
function renderBatch(batch: TempBatchItem, tbody: HTMLElement): void {
|
||||||
body.className = "b01-temp__group-body";
|
const opened = openGroups.has(batch.batch_id);
|
||||||
body.append(
|
const status = batch.required_complete
|
||||||
table(
|
? badge(L("B01_Temp_Status_Ready"), "success")
|
||||||
[
|
: badge(L("B01_Temp_Status_Uploading"), "warning");
|
||||||
L("B01_Temp_Table_Type"),
|
tbody.append(
|
||||||
L("B01_Temp_Table_Name"),
|
tableRow(COLUMNS, [
|
||||||
L("B01_Temp_Table_Size"),
|
nameWithCaret(batch, opened),
|
||||||
L("B01_Temp_Table_Status"),
|
status,
|
||||||
L("B01_Temp_Table_Action"),
|
leftCell(batch),
|
||||||
],
|
formatDate(batch.created_at),
|
||||||
fileRows(batch),
|
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);
|
function draw(batches: TempBatchItem[]): void {
|
||||||
return group;
|
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> {
|
async function refresh(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetchTempBatches();
|
const response = await fetchTempBatches();
|
||||||
retentionTag.textContent = `${response.retention_days}${L("B01_Temp_Hint_Days")}`;
|
retentionDays = response.retention_days;
|
||||||
progressNodes.clear();
|
draw(response.batches);
|
||||||
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,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 삭제 ────────────────────────────────────────────────────────────── */
|
/* ── 삭제 — 확인은 ⋯ 메뉴가 먼저 받음 ─────────────────────────────────── */
|
||||||
|
|
||||||
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
||||||
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
|
|
||||||
try {
|
try {
|
||||||
await deleteTempBatch(batch.batch_id);
|
await deleteTempBatch(batch.batch_id);
|
||||||
tasks.delete(batch.batch_id);
|
tasks.delete(batch.batch_id);
|
||||||
|
openGroups.delete(batch.batch_id);
|
||||||
showToast(L("B01_Temp_Delete_Success"), "success");
|
showToast(L("B01_Temp_Delete_Success"), "success");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -295,12 +331,7 @@ export function buildTempUploadSection(): HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeFile(
|
async function removeFile(batch: TempBatchItem, fileType: string): Promise<void> {
|
||||||
batch: TempBatchItem,
|
|
||||||
fileType: string,
|
|
||||||
fileName: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!window.confirm(`${fileName}\n${L("B01_Temp_File_Delete_Confirm")}`)) return;
|
|
||||||
try {
|
try {
|
||||||
await deleteTempBatchFile(batch.batch_id, fileType);
|
await deleteTempBatchFile(batch.batch_id, fileType);
|
||||||
showToast(L("B01_Temp_File_Delete_Success"), "success");
|
showToast(L("B01_Temp_File_Delete_Success"), "success");
|
||||||
@@ -453,17 +484,5 @@ export function buildTempUploadSection(): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void refresh();
|
void refresh();
|
||||||
|
return { root, openCreate: openCreateModal };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
|
||||||
import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
|
import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy";
|
||||||
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
|
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 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
|
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
|
||||||
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
|
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: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") },
|
||||||
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", 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: "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: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned },
|
||||||
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: legacy("temp") },
|
{ 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: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople },
|
||||||
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: legacy("company") },
|
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany },
|
||||||
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: legacy("companyDefaults") },
|
{ 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-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") },
|
{ 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:
|
def _run_views(body: str) -> object:
|
||||||
src = re.sub(r"^import[\s\S]*?;\n", "", _text(B01 / "B01_Dashboard_UI_Views.ts"), flags=re.M)
|
raw = _text(B01 / "B01_Dashboard_UI_Views.ts")
|
||||||
script = (
|
src = re.sub(r"^import[\s\S]*?;\n", "", raw, flags=re.M)
|
||||||
"const mountLegacy = () => {}; const mountGuide = () => {}; const mountChat = () => {};\n"
|
# 값으로 가져오는 이름(mount 함수들)은 빈 함수로 — 타입 가져오기는 건너뜀
|
||||||
+ src
|
names = [
|
||||||
+ "\n"
|
n.split(" as ")[-1].strip()
|
||||||
+ body
|
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(
|
result = subprocess.run(
|
||||||
["node", "--experimental-strip-types", "--no-warnings",
|
["node", "--experimental-strip-types", "--no-warnings",
|
||||||
"--input-type=module-typescript", "-e", script],
|
"--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