260717_0
This commit is contained in:
@@ -102,7 +102,6 @@ export async function renderPendingWorkflow(
|
||||
title: opts.title,
|
||||
steps: opts.steps,
|
||||
activeStep: opts.activeStep,
|
||||
leftPanel: buildPendingBlock(),
|
||||
mainContent: buildPendingBlock(),
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
|
||||
@@ -115,18 +115,6 @@ export interface ResourceData {
|
||||
stats: ResourceSnapshot;
|
||||
}
|
||||
|
||||
export interface AutomationItem {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name: string;
|
||||
logic_type: string;
|
||||
config_json: Record<string, unknown>;
|
||||
status: "DRAFT" | "ACTIVE" | "INACTIVE";
|
||||
last_executed_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name: string;
|
||||
position?: string | null;
|
||||
@@ -148,13 +136,6 @@ export interface AdminUpdateUserRequest extends UpdateUserRequest {
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationRequest {
|
||||
name: string;
|
||||
logic_type: string;
|
||||
config_json: Record<string, unknown>;
|
||||
status: "DRAFT" | "ACTIVE" | "INACTIVE";
|
||||
}
|
||||
|
||||
export interface CreateCompanyRequest {
|
||||
name: string;
|
||||
business_registration_number: string;
|
||||
@@ -335,38 +316,6 @@ export async function fetchSystemResources(days = 30): Promise<ResourceData> {
|
||||
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
|
||||
}
|
||||
|
||||
export async function fetchProjectAutomations(projectId: string): Promise<AutomationItem[]> {
|
||||
const data = await request<{ automations: AutomationItem[] }>(
|
||||
`/dashboard/projects/${encodeURIComponent(projectId)}/automations`,
|
||||
);
|
||||
return data.automations;
|
||||
}
|
||||
|
||||
export function createProjectAutomation(
|
||||
projectId: string,
|
||||
payload: AutomationRequest,
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/projects/${encodeURIComponent(projectId)}/automations`, {
|
||||
method: "POST",
|
||||
body: body(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectAutomation(
|
||||
automationId: number,
|
||||
payload: AutomationRequest,
|
||||
): Promise<unknown> {
|
||||
return request(`/dashboard/automations/${automationId}`, { method: "PUT", body: body(payload) });
|
||||
}
|
||||
|
||||
export function deleteProjectAutomation(automationId: number): Promise<unknown> {
|
||||
return request(`/dashboard/automations/${automationId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function executeProjectAutomation(automationId: number): Promise<unknown> {
|
||||
return request(`/dashboard/automations/${automationId}/execute`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function fetchProjectWorkflowState(projectId: string): Promise<WorkflowState> {
|
||||
return request(`/projects/${projectId}/workflow-state`, {
|
||||
method: "GET",
|
||||
|
||||
@@ -678,114 +678,3 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]:
|
||||
(bucket_seconds, bucket_seconds, since, bucket_seconds),
|
||||
)
|
||||
return {"current": current, "history": list(await cursor.fetchall()), "stats": current}
|
||||
|
||||
|
||||
async def list_project_automations(project_id: str) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, project_id, name, logic_type, config_json, status,
|
||||
last_executed_at, created_at, updated_at
|
||||
FROM project_automations
|
||||
WHERE project_id = %s AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC, created_at DESC""",
|
||||
(project_id,),
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def create_project_automation(
|
||||
project_id: str, actor_id: int, data: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""INSERT INTO project_automations
|
||||
(project_id, name, logic_type, config_json, status, created_by, updated_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
project_id,
|
||||
data["name"],
|
||||
data["logic_type"],
|
||||
json.dumps(data["config_json"], ensure_ascii=False),
|
||||
data["status"],
|
||||
actor_id,
|
||||
actor_id,
|
||||
),
|
||||
)
|
||||
automation_id = cursor.lastrowid
|
||||
await connection.commit()
|
||||
await cursor.execute("SELECT * FROM project_automations WHERE id = %s", (automation_id,))
|
||||
return await cursor.fetchone()
|
||||
|
||||
|
||||
async def get_project_automation(automation_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT a.*, p.company_id
|
||||
FROM project_automations a
|
||||
JOIN projects p ON p.id = a.project_id
|
||||
WHERE a.id = %s AND a.deleted_at IS NULL AND p.deleted_at IS NULL""",
|
||||
(automation_id,),
|
||||
)
|
||||
return await cursor.fetchone()
|
||||
|
||||
|
||||
async def update_project_automation(
|
||||
automation_id: int, actor_id: int, data: dict[str, Any]
|
||||
) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE project_automations
|
||||
SET name = %s, logic_type = %s, config_json = %s, status = %s, updated_by = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(
|
||||
data["name"],
|
||||
data["logic_type"],
|
||||
json.dumps(data["config_json"], ensure_ascii=False),
|
||||
data["status"],
|
||||
actor_id,
|
||||
automation_id,
|
||||
),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def delete_project_automation(automation_id: int, actor_id: int) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await connection.begin()
|
||||
await cursor.execute(
|
||||
"""UPDATE project_automations
|
||||
SET deleted_at = CURRENT_TIMESTAMP, updated_by = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(actor_id, automation_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
if changed:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
|
||||
VALUES (%s, 'AUTOMATION_DELETE', 'automation', %s)""",
|
||||
(actor_id, automation_id),
|
||||
)
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def mark_project_automation_executed(automation_id: int, actor_id: int) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE project_automations
|
||||
SET last_executed_at = CURRENT_TIMESTAMP, updated_by = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(actor_id, automation_id),
|
||||
)
|
||||
changed = cursor.rowcount > 0
|
||||
await connection.commit()
|
||||
return changed
|
||||
|
||||
@@ -10,14 +10,9 @@ from .B01_Dashboard_Repository import (
|
||||
add_company_member,
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
count_company_admins,
|
||||
create_company,
|
||||
create_project_automation,
|
||||
create_system_company,
|
||||
delete_project_automation,
|
||||
get_dashboard_me,
|
||||
get_project,
|
||||
get_project_automation,
|
||||
get_system_resources,
|
||||
get_user_admin_target,
|
||||
get_user_company,
|
||||
@@ -29,23 +24,19 @@ from .B01_Dashboard_Repository import (
|
||||
list_company_members,
|
||||
list_company_projects,
|
||||
list_join_requests,
|
||||
list_project_automations,
|
||||
list_user_projects,
|
||||
mark_project_automation_executed,
|
||||
process_join_request,
|
||||
remove_company_member,
|
||||
search_companies,
|
||||
soft_delete_project,
|
||||
update_admin_user,
|
||||
update_project,
|
||||
update_project_automation,
|
||||
update_user_profile,
|
||||
)
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
AssignCompanyRequest,
|
||||
AutomationRequest,
|
||||
ChangeUserRoleRequest,
|
||||
CreateCompanyRequest,
|
||||
JoinCompanyRequest,
|
||||
@@ -84,12 +75,6 @@ def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _can_manage_automation(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
return True
|
||||
return session["role"] == "ADMIN" and _same_company(session, project.get("company_id"))
|
||||
|
||||
|
||||
def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool:
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
return True
|
||||
@@ -361,81 +346,3 @@ async def system_resources(
|
||||
async def system_projects(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "projects": await list_all_projects()}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/automations")
|
||||
async def dashboard_project_automations(
|
||||
project_id: str,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
project = await get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if not _can_edit_project(session, project):
|
||||
raise HTTPException(status_code=403, detail="자동화 로직 조회 권한이 없습니다.")
|
||||
return {"status": "success", "automations": await list_project_automations(project_id)}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/automations")
|
||||
async def dashboard_create_automation(
|
||||
project_id: str,
|
||||
payload: AutomationRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
project = await get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if not _can_manage_automation(session, project):
|
||||
raise HTTPException(status_code=403, detail="자동화 로직 생성 권한이 없습니다.")
|
||||
automation = await create_project_automation(
|
||||
project_id, int(session["user_id"]), payload.model_dump()
|
||||
)
|
||||
return {"status": "success", "automation": automation}
|
||||
|
||||
|
||||
@router.put("/automations/{automation_id}")
|
||||
async def dashboard_update_automation(
|
||||
automation_id: int,
|
||||
payload: AutomationRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
automation = await get_project_automation(automation_id)
|
||||
if not automation:
|
||||
raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.")
|
||||
if not _can_manage_automation(session, automation):
|
||||
raise HTTPException(status_code=403, detail="자동화 로직 수정 권한이 없습니다.")
|
||||
if not await update_project_automation(
|
||||
automation_id, int(session["user_id"]), payload.model_dump()
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.delete("/automations/{automation_id}")
|
||||
async def dashboard_delete_automation(
|
||||
automation_id: int,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
automation = await get_project_automation(automation_id)
|
||||
if not automation:
|
||||
raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.")
|
||||
if not _can_manage_automation(session, automation):
|
||||
raise HTTPException(status_code=403, detail="자동화 로직 삭제 권한이 없습니다.")
|
||||
if not await delete_project_automation(automation_id, int(session["user_id"])):
|
||||
raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/automations/{automation_id}/execute")
|
||||
async def dashboard_execute_automation(
|
||||
automation_id: int,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
automation = await get_project_automation(automation_id)
|
||||
if not automation:
|
||||
raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.")
|
||||
if not _can_manage_automation(session, automation):
|
||||
raise HTTPException(status_code=403, detail="자동화 로직 실행 권한이 없습니다.")
|
||||
if not await mark_project_automation_executed(automation_id, int(session["user_id"])):
|
||||
raise HTTPException(status_code=404, detail="자동화 로직을 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
@@ -52,10 +52,3 @@ class AdminUpdateUserRequest(UpdateUserRequest):
|
||||
status: str | None = Field(
|
||||
default=None, pattern="^(NO_COMPANY|PENDING|ACTIVE|INACTIVE|REJECTED)$"
|
||||
)
|
||||
|
||||
|
||||
class AutomationRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
logic_type: str = Field(min_length=1, max_length=50)
|
||||
config_json: dict = Field(default_factory=dict)
|
||||
status: str = Field(default="DRAFT", pattern="^(DRAFT|ACTIVE|INACTIVE)$")
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createButton } from "@ui/ui_template_elements";
|
||||
import type { AuditLog, DashboardUser } from "./B01_Dashboard_Api_Fetch";
|
||||
import { canChangeRole } from "./B01_Dashboard_UI_Helper";
|
||||
import { openChangeRoleModal, openEditUserModal } from "./B01_Dashboard_UI_Modals";
|
||||
import { formatDate, L, table, text } from "./B01_Dashboard_UI_Common";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { formatDate, L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement {
|
||||
return table(
|
||||
|
||||
@@ -1,110 +1,17 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createCard,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import type { DashboardUser } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export function buildSectionHeader(
|
||||
titleText: string,
|
||||
actionButtons: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
const header = document.createElement("div");
|
||||
header.className = "b01-dashboard__section-header";
|
||||
header.style.display = "flex";
|
||||
header.style.justifyContent = "space-between";
|
||||
header.style.alignItems = "center";
|
||||
header.style.marginBottom = "var(--spacing-16)";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "ui-card__title";
|
||||
title.style.margin = "0";
|
||||
title.textContent = titleText;
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(...actionButtons);
|
||||
|
||||
header.append(title, actions);
|
||||
return header;
|
||||
}
|
||||
|
||||
export function section(
|
||||
title: string,
|
||||
body: HTMLElement,
|
||||
wide = false,
|
||||
actions: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
const header = buildSectionHeader(title, actions);
|
||||
const card = createCard({ body: [header, body], raised: true });
|
||||
card.classList.add("b01-dashboard__section");
|
||||
if (wide) card.classList.add("b01-dashboard__section--wide");
|
||||
return card;
|
||||
}
|
||||
|
||||
export function roleLabel(role: DashboardUser["role"]): string {
|
||||
if (role === "SYSTEM_ADMIN") return L("B01_Dashboard_Role_SystemAdmin");
|
||||
if (role === "ADMIN") return L("B01_Dashboard_Role_Admin");
|
||||
return L("B01_Dashboard_Role_User");
|
||||
}
|
||||
|
||||
export function table(headers: string[], rows: HTMLElement[][]): HTMLElement {
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b01-dashboard__empty";
|
||||
empty.textContent = L("Common_Status_Empty");
|
||||
return empty;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__table-wrap";
|
||||
const tableEl = document.createElement("table");
|
||||
tableEl.className = "b01-dashboard__table";
|
||||
const thead = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const header of headers) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = header;
|
||||
headRow.append(th);
|
||||
}
|
||||
thead.append(headRow);
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const cell of row) {
|
||||
const td = document.createElement("td");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
tableEl.append(thead, tbody);
|
||||
wrap.append(tableEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function text(value: unknown): HTMLElement {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null || value === "" ? "-" : String(value);
|
||||
return span;
|
||||
}
|
||||
|
||||
export function actionPair(approve: () => void, reject: () => void): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b01-dashboard__actions";
|
||||
row.append(
|
||||
createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }),
|
||||
createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function runRequest(action: () => Promise<unknown>): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
openFindCompanyModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { actionPair, formatDate, L, runRequest, table, text } from "./B01_Dashboard_UI_Common";
|
||||
import { actionPair, table, text } from "@ui/ui_template_general_blocks";
|
||||
import { formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
@@ -26,10 +26,3 @@ export function canDeleteUser(user: DashboardUser, _targetUser: Member | Dashboa
|
||||
// ADMIN은 본인 회사의 멤버만 삭제 가능
|
||||
return user.role === "ADMIN" && user.company_id !== null;
|
||||
}
|
||||
|
||||
export function canManageAutomation(
|
||||
user: DashboardUser,
|
||||
_projectOrAutomation: ProjectItem | { company_id?: number | null },
|
||||
): boolean {
|
||||
return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ import {
|
||||
changeUserRole,
|
||||
updateDashboardUser,
|
||||
removeCompanyMember,
|
||||
fetchProjectAutomations,
|
||||
createProjectAutomation,
|
||||
updateProjectAutomation,
|
||||
deleteProjectAutomation,
|
||||
executeProjectAutomation,
|
||||
createCompany,
|
||||
joinCompany,
|
||||
searchCompanies,
|
||||
@@ -25,7 +20,6 @@ import {
|
||||
type DashboardUser,
|
||||
type ProjectItem,
|
||||
type Member,
|
||||
type AutomationItem,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -282,136 +276,3 @@ export function openAddMemberModal(): void {
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAutomationModal(project: ProjectItem): Promise<void> {
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b01-dashboard__modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-dashboard__modal-panel b01-dashboard__modal-panel--wide";
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "b01-dashboard__modal-title";
|
||||
heading.textContent = `${project.name} - ${L("B01_Dashboard_AutomationLogic")}`;
|
||||
|
||||
const listWrap = document.createElement("div");
|
||||
listWrap.className = "b01-dashboard__automation-list";
|
||||
|
||||
const refreshList = async () => {
|
||||
listWrap.innerHTML = L("Common_Status_Loading");
|
||||
try {
|
||||
const data = await fetchProjectAutomations(project.id);
|
||||
listWrap.innerHTML = "";
|
||||
if (data.length === 0) {
|
||||
listWrap.textContent = L("Common_Status_Empty");
|
||||
return;
|
||||
}
|
||||
data.forEach((item) => {
|
||||
const itemEl = document.createElement("div");
|
||||
itemEl.className = "b01-dashboard__automation-item";
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.innerHTML = `<strong>${item.name}</strong> (${item.logic_type}) - status: ${item.status}`;
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
|
||||
const runBtn = createButton({
|
||||
label: L("B01_Dashboard_ExecuteAutomation"),
|
||||
variant: "ghost",
|
||||
onClick: async () => {
|
||||
await executeProjectAutomation(item.id);
|
||||
showToast("실행 성공", "success");
|
||||
refreshList();
|
||||
},
|
||||
});
|
||||
|
||||
const editBtn = createButton({
|
||||
label: L("Common_Btn_Edit"),
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
modal.remove();
|
||||
openEditAutomationModal(project, item);
|
||||
},
|
||||
});
|
||||
|
||||
const delBtn = createButton({
|
||||
label: L("Common_Btn_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => {
|
||||
modal.remove();
|
||||
openDeleteAutomationModal(project, item);
|
||||
},
|
||||
});
|
||||
|
||||
actions.append(runBtn, editBtn, delBtn);
|
||||
itemEl.append(info, actions);
|
||||
listWrap.append(itemEl);
|
||||
});
|
||||
} catch (e) {
|
||||
listWrap.textContent = "로딩 에러";
|
||||
}
|
||||
};
|
||||
|
||||
const createBtn = createButton({
|
||||
label: L("B01_Dashboard_CreateAutomation"),
|
||||
onClick: () => {
|
||||
modal.remove();
|
||||
openCreateAutomationModal(project);
|
||||
},
|
||||
});
|
||||
|
||||
const closeBtn = createButton({
|
||||
label: L("Common_Btn_Close"),
|
||||
variant: "ghost",
|
||||
onClick: () => modal.remove(),
|
||||
});
|
||||
|
||||
panel.append(heading, createBtn, listWrap, closeBtn);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
|
||||
await refreshList();
|
||||
}
|
||||
|
||||
function openCreateAutomationModal(project: ProjectItem): void {
|
||||
const name = createInputField({ label: "자동화 로직명", required: true });
|
||||
const type = createInputField({ label: "로직 타입", required: true });
|
||||
|
||||
openModal(L("B01_Dashboard_CreateAutomation"), [name.root, type.root], async () => {
|
||||
await createProjectAutomation(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
logic_type: type.input.value.trim(),
|
||||
config_json: {},
|
||||
status: "DRAFT",
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
setTimeout(() => openAutomationModal(project), 300);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditAutomationModal(project: ProjectItem, item: AutomationItem): void {
|
||||
const name = createInputField({ label: "자동화 로직명", value: item.name, required: true });
|
||||
const type = createInputField({ label: "로직 타입", value: item.logic_type, required: true });
|
||||
|
||||
openModal(L("B01_Dashboard_EditAutomation"), [name.root, type.root], async () => {
|
||||
await updateProjectAutomation(item.id, {
|
||||
name: name.input.value.trim(),
|
||||
logic_type: type.input.value.trim(),
|
||||
config_json: item.config_json,
|
||||
status: item.status,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
setTimeout(() => openAutomationModal(project), 300);
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteAutomationModal(project: ProjectItem, item: AutomationItem): void {
|
||||
const warning = document.createElement("p");
|
||||
warning.textContent = L("B01_Dashboard_DeleteAutomation") + "?";
|
||||
|
||||
openModal(L("B01_Dashboard_DeleteAutomation"), [warning], async () => {
|
||||
await deleteProjectAutomation(item.id);
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
setTimeout(() => openAutomationModal(project), 300);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
type ResourceData,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
|
||||
import { L, roleLabel, section } from "./B01_Dashboard_UI_Common";
|
||||
import { L, roleLabel } from "./B01_Dashboard_UI_Common";
|
||||
import {
|
||||
buildCompanyPanel,
|
||||
companyTable,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { createStepBar } from "@ui/ui_template_workflow_layout";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import type { DashboardUser, ProjectItem } from "./B01_Dashboard_Api_Fetch";
|
||||
import { canDeleteProject, canEditProject, canManageAutomation } from "./B01_Dashboard_UI_Helper";
|
||||
import {
|
||||
openAutomationModal,
|
||||
openDeleteProjectModal,
|
||||
openEditProjectModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import { formatDate, L, table, text } from "./B01_Dashboard_UI_Common";
|
||||
import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper";
|
||||
import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals";
|
||||
import { formatDate, L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement {
|
||||
return table(
|
||||
@@ -43,15 +40,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (canManageAutomation(currentUser, project)) {
|
||||
actCell.append(
|
||||
createButton({
|
||||
label: L("B01_Dashboard_AutomationLogic"),
|
||||
variant: "ghost",
|
||||
onClick: () => openAutomationModal(project),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
text(project.name),
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
@@ -29,151 +31,24 @@ import {
|
||||
saveB03UploadedFile,
|
||||
updateB03AnalysisState,
|
||||
} from "./B03_FileInput_State";
|
||||
import {
|
||||
createFileCardTemplate,
|
||||
formatBytes,
|
||||
formatEta,
|
||||
getExtension,
|
||||
initializeSlots,
|
||||
makeSessionKey,
|
||||
type FileSlot,
|
||||
type FileSlotState,
|
||||
type StoredUploadSession,
|
||||
type UploadStatus,
|
||||
} from "./B03_FileInput_UI_Support";
|
||||
import "./B03_FileInput_UI_Style.css";
|
||||
|
||||
type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
interface SlotConfig {
|
||||
slot: FileSlot;
|
||||
labelKey: keyof typeof ui_locales;
|
||||
icon: string;
|
||||
extensions: readonly string[];
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
interface FileSlotState extends SlotConfig {
|
||||
file?: File;
|
||||
uploadSessionId?: string;
|
||||
uploadStatus: UploadStatus;
|
||||
progressBytes: number;
|
||||
speedMbs: number;
|
||||
etaSeconds: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StoredUploadSession {
|
||||
key: string;
|
||||
projectId: string;
|
||||
slot: FileSlot;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
uploadSessionId: string;
|
||||
chunkSizeBytes: number;
|
||||
totalChunks: number;
|
||||
completedChunks: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
{
|
||||
slot: "las_laz",
|
||||
labelKey: "B03_File_Slot_PointCloud",
|
||||
icon: "●",
|
||||
extensions: [".las", ".laz"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "prj",
|
||||
labelKey: "B03_File_Slot_Projection",
|
||||
icon: "◇",
|
||||
extensions: [".prj"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "tfw",
|
||||
labelKey: "B03_File_Slot_RasterCoord",
|
||||
icon: "□",
|
||||
extensions: [".tfw"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "tif",
|
||||
labelKey: "B03_File_Slot_TerrainDem",
|
||||
icon: "▧",
|
||||
extensions: [".tif"],
|
||||
isRequired: false,
|
||||
},
|
||||
];
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatEta(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return "-";
|
||||
if (seconds < 60) return `${Math.ceil(seconds)}s`;
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function makeSessionKey(projectId: string, file: File): string {
|
||||
return `b03_upload_${projectId}_${file.name}_${file.size}`;
|
||||
}
|
||||
|
||||
function initializeSlots(): Map<FileSlot, FileSlotState> {
|
||||
const map = new Map<FileSlot, FileSlotState>();
|
||||
for (const config of SLOT_CONFIGS) {
|
||||
map.set(config.slot, {
|
||||
...config,
|
||||
uploadStatus: "pending",
|
||||
progressBytes: 0,
|
||||
speedMbs: 0,
|
||||
etaSeconds: null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function createFileCardTemplate(): HTMLTemplateElement {
|
||||
const template = document.createElement("template");
|
||||
template.id = "file-card-template";
|
||||
template.innerHTML = `
|
||||
<article class="b03-file__card b03-file__card--empty">
|
||||
<div class="b03-file__card-header">
|
||||
<span class="b03-file__card-icon" aria-hidden="true"></span>
|
||||
<div class="b03-file__card-heading">
|
||||
<strong class="b03-file__card-label"></strong>
|
||||
<span class="b03-file__card-ext"></span>
|
||||
</div>
|
||||
<div class="b03-file__card-badge-container"></div>
|
||||
<button class="b03-file__card-remove" type="button" aria-label="파일 제거"></button>
|
||||
</div>
|
||||
<div class="b03-file__card-content">
|
||||
<button class="b03-file__card-select" type="button"></button>
|
||||
<input class="b03-file__slot-input" type="file" />
|
||||
<div class="b03-file__file-info">
|
||||
<span class="b03-file__file-name"></span>
|
||||
<span class="b03-file__file-size"></span>
|
||||
</div>
|
||||
<div class="b03-file__progress-section">
|
||||
<div class="b03-file__progress-bar-container">
|
||||
<div class="b03-file__progress-bar"></div>
|
||||
</div>
|
||||
<div class="b03-file__progress-info">
|
||||
<span class="b03-file__progress-bytes"></span>
|
||||
<span class="b03-file__progress-speed"></span>
|
||||
<span class="b03-file__progress-eta"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b03-file__error-message" role="alert"></div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const slots = initializeSlots();
|
||||
const cardMap = new Map<FileSlot, HTMLElement>();
|
||||
@@ -660,29 +535,36 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
cardsContainer.className =
|
||||
"b03-file__main-layout b03-file__control-panel b03-file__cards-container-panel"; // 동일한 양식으로 감싸기
|
||||
cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
cardsContainer.append(filesGroup);
|
||||
|
||||
const workflowState = activeProjectId
|
||||
? await fetchWorkflowState(activeProjectId).catch(() => undefined)
|
||||
: undefined;
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B03_File_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 0,
|
||||
leftPanel: uploadControlPanel,
|
||||
mainContent: cardsContainer,
|
||||
const steps = workflowSteps();
|
||||
const progressContent = createStepBar(steps, 0, {
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
orientation: "vertical",
|
||||
icons: WORKFLOW_STEP_ICONS,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (activeProjectId) {
|
||||
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
}
|
||||
},
|
||||
});
|
||||
layout.root.classList.add("b03-file");
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b03-file",
|
||||
content: [uploadControlPanel, cardsContainer],
|
||||
});
|
||||
layout.content.classList.add("b03-file__main-layout");
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: L("B03_File_Title"),
|
||||
progressContent,
|
||||
showTitlePanel: false,
|
||||
});
|
||||
layout.root.append(overlays.root);
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
for (const slot of slots.keys()) renderSlot(slot);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
export type FileSlot = "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
slot: FileSlot;
|
||||
labelKey: keyof typeof ui_locales;
|
||||
icon: string;
|
||||
extensions: readonly string[];
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
export interface FileSlotState extends SlotConfig {
|
||||
file?: File;
|
||||
uploadSessionId?: string;
|
||||
uploadStatus: UploadStatus;
|
||||
progressBytes: number;
|
||||
speedMbs: number;
|
||||
etaSeconds: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StoredUploadSession {
|
||||
key: string;
|
||||
projectId: string;
|
||||
slot: FileSlot;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
uploadSessionId: string;
|
||||
chunkSizeBytes: number;
|
||||
totalChunks: number;
|
||||
completedChunks: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SLOT_CONFIGS: readonly SlotConfig[] = [
|
||||
{
|
||||
slot: "las_laz",
|
||||
labelKey: "B03_File_Slot_PointCloud",
|
||||
icon: "●",
|
||||
extensions: [".las", ".laz"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "prj",
|
||||
labelKey: "B03_File_Slot_Projection",
|
||||
icon: "◇",
|
||||
extensions: [".prj"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "tfw",
|
||||
labelKey: "B03_File_Slot_RasterCoord",
|
||||
icon: "□",
|
||||
extensions: [".tfw"],
|
||||
isRequired: true,
|
||||
},
|
||||
{
|
||||
slot: "tif",
|
||||
labelKey: "B03_File_Slot_TerrainDem",
|
||||
icon: "▧",
|
||||
extensions: [".tif"],
|
||||
isRequired: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function getExtension(fileName: string): string {
|
||||
const index = fileName.lastIndexOf(".");
|
||||
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return "-";
|
||||
if (seconds < 60) return `${Math.ceil(seconds)}s`;
|
||||
return `${Math.ceil(seconds / 60)}m`;
|
||||
}
|
||||
|
||||
export function makeSessionKey(projectId: string, file: File): string {
|
||||
return `b03_upload_${projectId}_${file.name}_${file.size}`;
|
||||
}
|
||||
|
||||
export function initializeSlots(): Map<FileSlot, FileSlotState> {
|
||||
const map = new Map<FileSlot, FileSlotState>();
|
||||
for (const config of SLOT_CONFIGS) {
|
||||
map.set(config.slot, {
|
||||
...config,
|
||||
uploadStatus: "pending",
|
||||
progressBytes: 0,
|
||||
speedMbs: 0,
|
||||
etaSeconds: null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function createFileCardTemplate(): HTMLTemplateElement {
|
||||
const template = document.createElement("template");
|
||||
template.id = "file-card-template";
|
||||
template.innerHTML = `
|
||||
<article class="b03-file__card b03-file__card--empty">
|
||||
<div class="b03-file__card-header">
|
||||
<span class="b03-file__card-icon" aria-hidden="true"></span>
|
||||
<div class="b03-file__card-heading">
|
||||
<strong class="b03-file__card-label"></strong>
|
||||
<span class="b03-file__card-ext"></span>
|
||||
</div>
|
||||
<div class="b03-file__card-badge-container"></div>
|
||||
<button class="b03-file__card-remove" type="button"></button>
|
||||
</div>
|
||||
<div class="b03-file__card-content">
|
||||
<button class="b03-file__card-select" type="button"></button>
|
||||
<input class="b03-file__slot-input" type="file" />
|
||||
<div class="b03-file__file-info">
|
||||
<span class="b03-file__file-name"></span>
|
||||
<span class="b03-file__file-size"></span>
|
||||
</div>
|
||||
<div class="b03-file__progress-section">
|
||||
<div class="b03-file__progress-bar-container">
|
||||
<div class="b03-file__progress-bar"></div>
|
||||
</div>
|
||||
<div class="b03-file__progress-info">
|
||||
<span class="b03-file__progress-bytes"></span>
|
||||
<span class="b03-file__progress-speed"></span>
|
||||
<span class="b03-file__progress-eta"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b03-file__error-message" role="alert"></div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
return template;
|
||||
}
|
||||
@@ -30,6 +30,13 @@ export interface SurfaceAnalyzeResponse {
|
||||
surface_model_ids: number[];
|
||||
}
|
||||
|
||||
export interface SurfaceConfirmResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
model_id: number;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 저장된 지표면 모델 요약 (SurfaceModelSummary) */
|
||||
export interface SurfaceModelSummary {
|
||||
id: number;
|
||||
@@ -37,6 +44,7 @@ export interface SurfaceModelSummary {
|
||||
status: string;
|
||||
resolution_m: number | null;
|
||||
model_file_path: string | null;
|
||||
generation_params: Record<string, unknown> | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
@@ -131,6 +139,17 @@ export async function listSurfaceModels(projectId: string): Promise<SurfaceModel
|
||||
});
|
||||
}
|
||||
|
||||
/** 선택한 지표면 모델을 확정하고 WF1 단계를 완료한다. */
|
||||
export async function confirmSurfaceModel(
|
||||
projectId: string,
|
||||
modelId: number,
|
||||
): Promise<SurfaceConfirmResponse> {
|
||||
return requestJson<SurfaceConfirmResponse>(`/projects/${projectId}/surface/confirm`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model_id: modelId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSurfaceInputFiles(
|
||||
projectId: string,
|
||||
): Promise<SurfaceInputFileListResponse> {
|
||||
|
||||
@@ -237,7 +237,8 @@ async def list_surface_models(
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, model_type, status, resolution_m, model_file_path, created_at
|
||||
SELECT id, model_type, status, resolution_m, model_file_path,
|
||||
generation_params, created_at
|
||||
FROM surface_models
|
||||
WHERE project_id = %s
|
||||
ORDER BY created_at DESC
|
||||
@@ -246,14 +247,58 @@ async def list_surface_models(
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": int(row[0]),
|
||||
"model_type": row[1],
|
||||
"status": row[2],
|
||||
"resolution_m": row[3],
|
||||
"model_file_path": row[4],
|
||||
"created_at": row[5].isoformat() if row[5] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
models: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
generation_params = row[5]
|
||||
if isinstance(generation_params, str):
|
||||
generation_params = json.loads(generation_params)
|
||||
models.append(
|
||||
{
|
||||
"id": int(row[0]),
|
||||
"model_type": row[1],
|
||||
"status": row[2],
|
||||
"resolution_m": row[3],
|
||||
"model_file_path": row[4],
|
||||
"generation_params": generation_params,
|
||||
"created_at": row[6].isoformat() if row[6] else None,
|
||||
}
|
||||
)
|
||||
return models
|
||||
|
||||
|
||||
async def clear_confirmed_surface_models(connection: aiomysql.Connection, project_id: UUID) -> None:
|
||||
"""재분석 시 기존 확정을 해제하여 새 분석 결과의 재확정을 요구한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET status = 'COMPLETE'
|
||||
WHERE project_id = %s AND status = 'CONFIRMED'
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
|
||||
|
||||
async def confirm_surface_model(
|
||||
connection: aiomysql.Connection, project_id: UUID, model_id: int
|
||||
) -> None:
|
||||
"""프로젝트 내 단일 모델만 CONFIRMED가 되도록 갱신한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET status = 'COMPLETE'
|
||||
WHERE project_id = %s AND status = 'CONFIRMED'
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE surface_models
|
||||
SET status = 'CONFIRMED'
|
||||
WHERE id = %s AND project_id = %s AND status = 'COMPLETE'
|
||||
""",
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise LookupError("확정할 지표면 모델을 찾을 수 없습니다.")
|
||||
|
||||
@@ -15,6 +15,8 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
confirm_surface_model,
|
||||
create_processed_point_cloud,
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
@@ -25,6 +27,8 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceAnalyzeRequest,
|
||||
SurfaceAnalyzeResponse,
|
||||
SurfaceConfirmRequest,
|
||||
SurfaceConfirmResponse,
|
||||
SurfaceGroundStatsResponse,
|
||||
SurfaceInputFileListResponse,
|
||||
SurfaceInputFileSummary,
|
||||
@@ -34,7 +38,12 @@ from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
fail_stage,
|
||||
start_stage,
|
||||
update_stage_progress,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -166,6 +175,7 @@ async def analyze_surface(
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 1, params)
|
||||
await clear_confirmed_surface_models(connection, project_id)
|
||||
await connection.commit()
|
||||
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
@@ -206,13 +216,18 @@ async def analyze_surface(
|
||||
source_filters=source_filters,
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 1)
|
||||
await update_stage_progress(cursor, str(project_id), 1, 100)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
write_surface_progress(project_root, 100, "completed", "WF1 분석이 완료되었습니다.")
|
||||
write_surface_progress(
|
||||
project_root,
|
||||
100,
|
||||
"awaiting_confirmation",
|
||||
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
|
||||
)
|
||||
|
||||
return SurfaceAnalyzeResponse(
|
||||
project_id=str(project_id),
|
||||
@@ -257,6 +272,34 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/surface/confirm", response_model=SurfaceConfirmResponse)
|
||||
async def confirm_surface(
|
||||
project_id: UUID, request: SurfaceConfirmRequest
|
||||
) -> SurfaceConfirmResponse | JSONResponse:
|
||||
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await confirm_surface_model(connection, project_id, request.model_id)
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 1)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 지표면 모델 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지표면 모델 확정 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/input-files", response_model=SurfaceInputFileListResponse)
|
||||
async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListResponse | JSONResponse:
|
||||
"""프로젝트의 WF1 분석 대상 LAS/LAZ 입력 파일 목록을 조회한다."""
|
||||
|
||||
@@ -42,6 +42,23 @@ class SurfaceAnalyzeRequest(BaseModel):
|
||||
return methods
|
||||
|
||||
|
||||
class SurfaceConfirmRequest(BaseModel):
|
||||
"""사용자가 선택한 지표면 모델 확정 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
model_id: int = Field(gt=0, description="확정할 surface_models.id")
|
||||
|
||||
|
||||
class SurfaceConfirmResponse(BaseModel):
|
||||
"""지표면 모델 확정 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
model_id: int
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class SurfaceModelSummary(BaseModel):
|
||||
"""저장된 지표면 모델 요약."""
|
||||
|
||||
@@ -50,6 +67,7 @@ class SurfaceModelSummary(BaseModel):
|
||||
status: str
|
||||
resolution_m: float | None = None
|
||||
model_file_path: str | None = None
|
||||
generation_params: dict[str, Any] | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
fetchGisGeoJson,
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
type VWorldMeta,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export interface SurfaceMapViewer {
|
||||
root: HTMLElement;
|
||||
render: (projectId: string) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
type GeoJsonGeometry = {
|
||||
type: string;
|
||||
coordinates: unknown;
|
||||
};
|
||||
|
||||
type GeoJsonFeature = {
|
||||
geometry?: GeoJsonGeometry | null;
|
||||
};
|
||||
|
||||
type GeoJsonCollection = {
|
||||
features?: GeoJsonFeature[];
|
||||
};
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function makeOption(value: string, label: string): HTMLOptionElement {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
return option;
|
||||
}
|
||||
|
||||
function prettyScaleDistance(roughMeters: number): number {
|
||||
const candidates = [2, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
|
||||
return candidates.find((value) => value >= roughMeters) ?? candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b04-map";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "b04-map__header";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = L("B04_Surface_Map_Title");
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "b04-map__controls";
|
||||
const backgroundLabel = document.createElement("label");
|
||||
backgroundLabel.textContent = L("B04_Surface_Map_Background");
|
||||
const backgroundSelect = document.createElement("select");
|
||||
backgroundSelect.append(
|
||||
makeOption("none", L("B04_Surface_Map_None")),
|
||||
makeOption("satellite", L("B04_Surface_Map_Satellite")),
|
||||
makeOption("hybrid", L("B04_Surface_Map_Hybrid")),
|
||||
makeOption("white", L("B04_Surface_Map_White")),
|
||||
);
|
||||
backgroundLabel.append(backgroundSelect);
|
||||
|
||||
const gisLabel = document.createElement("label");
|
||||
gisLabel.textContent = L("B04_Surface_Map_GisLayer");
|
||||
const gisSelect = document.createElement("select");
|
||||
gisSelect.append(
|
||||
makeOption("none", L("B04_Surface_Map_None")),
|
||||
makeOption("지적도", L("B04_Surface_Map_Cadastral")),
|
||||
makeOption("수계망", L("B04_Surface_Map_Water")),
|
||||
makeOption("산사태", L("B04_Surface_Map_Landslide")),
|
||||
makeOption("행정구역_시군구", L("B04_Surface_Map_Sigungu")),
|
||||
makeOption("행정구역_읍면동", L("B04_Surface_Map_Eupmyeondong")),
|
||||
);
|
||||
gisLabel.append(gisSelect);
|
||||
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.type = "button";
|
||||
resetButton.textContent = L("B04_Surface_Map_Reset");
|
||||
controls.append(backgroundLabel, gisLabel, resetButton);
|
||||
header.append(title, controls);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b04-map__viewport";
|
||||
const image = document.createElement("img");
|
||||
image.className = "b04-map__image";
|
||||
image.alt = L("B04_Surface_Map_ImageAlt");
|
||||
image.draggable = false;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "b04-map__canvas";
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b04-map__empty";
|
||||
empty.textContent = L("B04_Surface_Map_Empty");
|
||||
const status = document.createElement("span");
|
||||
status.className = "b04-map__status";
|
||||
const scaleBar = document.createElement("div");
|
||||
scaleBar.className = "b04-map__scale";
|
||||
const scaleText = document.createElement("span");
|
||||
scaleBar.append(scaleText);
|
||||
viewport.append(image, canvas, empty, status, scaleBar);
|
||||
root.append(header, viewport);
|
||||
|
||||
let currentProjectId: string | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let geoJson: GeoJsonCollection | null = null;
|
||||
let scale = 1;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
let loadSequence = 0;
|
||||
|
||||
function updateImageTransform(): void {
|
||||
image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||
}
|
||||
|
||||
function resetView(): void {
|
||||
scale = 1;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
updateImageTransform();
|
||||
drawVectorLayer();
|
||||
}
|
||||
|
||||
function toCanvasPoint(
|
||||
lon: number,
|
||||
lat: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): [number, number] {
|
||||
if (!meta) return [0, 0];
|
||||
const lonRange = meta.lon_max - meta.lon_min || 1;
|
||||
const latRange = meta.lat_max - meta.lat_min || 1;
|
||||
const baseX = ((lon - meta.lon_min) / lonRange) * width;
|
||||
const baseY = height - ((lat - meta.lat_min) / latRange) * height;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
return [
|
||||
centerX + (baseX - centerX) * scale + offsetX,
|
||||
centerY + (baseY - centerY) * scale + offsetY,
|
||||
];
|
||||
}
|
||||
|
||||
function drawRing(
|
||||
context: CanvasRenderingContext2D,
|
||||
ring: unknown,
|
||||
width: number,
|
||||
height: number,
|
||||
fill: boolean,
|
||||
): void {
|
||||
if (!Array.isArray(ring) || ring.length === 0) return;
|
||||
const points = ring.filter(
|
||||
(point): point is [number, number] =>
|
||||
Array.isArray(point) && typeof point[0] === "number" && typeof point[1] === "number",
|
||||
);
|
||||
if (points.length === 0) return;
|
||||
context.beginPath();
|
||||
points.forEach(([lon, lat], index) => {
|
||||
const [x, y] = toCanvasPoint(lon, lat, width, height);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
if (fill) {
|
||||
context.closePath();
|
||||
context.save();
|
||||
context.globalAlpha = 0.16;
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawGeometry(
|
||||
context: CanvasRenderingContext2D,
|
||||
geometry: GeoJsonGeometry,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
const coordinates = geometry.coordinates;
|
||||
if (!Array.isArray(coordinates)) return;
|
||||
if (geometry.type === "LineString") {
|
||||
drawRing(context, coordinates, width, height, false);
|
||||
} else if (geometry.type === "MultiLineString") {
|
||||
coordinates.forEach((line) => drawRing(context, line, width, height, false));
|
||||
} else if (geometry.type === "Polygon") {
|
||||
coordinates.forEach((ring) => drawRing(context, ring, width, height, true));
|
||||
} else if (geometry.type === "MultiPolygon") {
|
||||
coordinates.forEach((polygon) => {
|
||||
if (Array.isArray(polygon)) {
|
||||
polygon.forEach((ring) => drawRing(context, ring, width, height, true));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawScaleBar(width: number): void {
|
||||
if (!meta || width <= 0) {
|
||||
scaleBar.hidden = true;
|
||||
return;
|
||||
}
|
||||
const metersPerPixel = meta.width_meters / width / scale;
|
||||
const meters = prettyScaleDistance(100 * metersPerPixel);
|
||||
const pixels = meters / metersPerPixel;
|
||||
scaleBar.hidden = false;
|
||||
scaleBar.style.width = `${pixels}px`;
|
||||
scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`;
|
||||
}
|
||||
|
||||
function drawVectorLayer(): void {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const height = Math.max(1, Math.floor(rect.height));
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const styles = getComputedStyle(root);
|
||||
context.strokeStyle = styles.getPropertyValue("--b04-map-vector").trim();
|
||||
context.fillStyle = styles.getPropertyValue("--b04-map-vector").trim();
|
||||
context.lineWidth = 1.5;
|
||||
geoJson?.features?.forEach((feature) => {
|
||||
if (feature.geometry) drawGeometry(context, feature.geometry, width, height);
|
||||
});
|
||||
updateImageTransform();
|
||||
drawScaleBar(width);
|
||||
}
|
||||
|
||||
async function loadLayers(): Promise<void> {
|
||||
if (!currentProjectId) return;
|
||||
const sequence = ++loadSequence;
|
||||
const background = backgroundSelect.value;
|
||||
const gisLayer = gisSelect.value;
|
||||
empty.hidden = background !== "none" || gisLayer !== "none";
|
||||
image.hidden = background === "none";
|
||||
image.removeAttribute("src");
|
||||
meta = null;
|
||||
geoJson = null;
|
||||
resetView();
|
||||
if (background === "none" && gisLayer === "none") {
|
||||
status.textContent = "";
|
||||
return;
|
||||
}
|
||||
status.textContent = L("B04_Surface_Map_Loading");
|
||||
const mapLayer = background === "none" ? "white" : background;
|
||||
try {
|
||||
const [nextMeta, nextGeoJson] = await Promise.all([
|
||||
fetchVWorldMeta(currentProjectId, mapLayer),
|
||||
gisLayer === "none" ? Promise.resolve(null) : fetchGisGeoJson(currentProjectId, gisLayer),
|
||||
]);
|
||||
if (sequence !== loadSequence) return;
|
||||
meta = nextMeta;
|
||||
geoJson = nextGeoJson as GeoJsonCollection | null;
|
||||
if (background !== "none") {
|
||||
image.src = `${getVWorldMapUrl(currentProjectId, mapLayer)}&_t=${Date.now()}`;
|
||||
}
|
||||
status.textContent = geoJson?.features
|
||||
? L("B04_Surface_Map_Features").replace("{count}", geoJson.features.length.toLocaleString())
|
||||
: "";
|
||||
drawVectorLayer();
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
status.textContent = error instanceof Error ? error.message : L("B04_Surface_Map_LoadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
backgroundSelect.addEventListener("change", () => void loadLayers());
|
||||
gisSelect.addEventListener("change", () => void loadLayers());
|
||||
resetButton.addEventListener("click", resetView);
|
||||
viewport.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
scale = Math.min(8, Math.max(0.5, scale * (event.deltaY < 0 ? 1.15 : 0.87)));
|
||||
drawVectorLayer();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
viewport.addEventListener("pointerdown", (event) => {
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY };
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
});
|
||||
viewport.addEventListener("pointermove", (event) => {
|
||||
if (!dragStart) return;
|
||||
offsetX = dragStart.offsetX + event.clientX - dragStart.x;
|
||||
offsetY = dragStart.offsetY + event.clientY - dragStart.y;
|
||||
drawVectorLayer();
|
||||
});
|
||||
const stopDragging = (): void => {
|
||||
dragStart = null;
|
||||
};
|
||||
viewport.addEventListener("pointerup", stopDragging);
|
||||
viewport.addEventListener("pointercancel", stopDragging);
|
||||
|
||||
const resizeObserver = new ResizeObserver(drawVectorLayer);
|
||||
resizeObserver.observe(viewport);
|
||||
|
||||
return {
|
||||
root,
|
||||
render(projectId) {
|
||||
currentProjectId = projectId;
|
||||
void loadLayers();
|
||||
},
|
||||
dispose() {
|
||||
loadSequence += 1;
|
||||
resizeObserver.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
analyzeSurface,
|
||||
confirmSurfaceModel,
|
||||
fetchSurfaceGroundStats,
|
||||
fetchSurfacePointCloud,
|
||||
fetchSurfaceStatus,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
||||
import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer";
|
||||
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
|
||||
import "./B04_wf1_Surface_UI_Style.css";
|
||||
|
||||
@@ -113,6 +115,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
statsList.className = "b04-surface__stats";
|
||||
const viewer = createSurfacePointCloudViewer();
|
||||
const terrainViewer = createSurfaceTerrainViewer();
|
||||
const mapViewer = createSurfaceMapViewer();
|
||||
|
||||
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
|
||||
"grid_min_z",
|
||||
@@ -194,7 +197,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
modelsSection.append(modelsTitle, modelList);
|
||||
|
||||
bottom.append(statsSection, modelsSection);
|
||||
workspace.append(topbar, viewer.root, terrainViewer.root, bottom);
|
||||
workspace.append(topbar, viewer.root, terrainViewer.root, mapViewer.root, bottom);
|
||||
|
||||
let workflowState: WorkflowState | undefined;
|
||||
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
@@ -228,6 +231,22 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
return projectId;
|
||||
}
|
||||
|
||||
function enableRouteStep(projectId: string): void {
|
||||
const routeButton = layout.root.querySelectorAll<HTMLButtonElement>(
|
||||
".ui-workflow-layout__step",
|
||||
)[2];
|
||||
if (!routeButton) return;
|
||||
routeButton.disabled = false;
|
||||
routeButton.classList.add("is-enabled");
|
||||
routeButton.classList.remove("state-not_started", "state-stale");
|
||||
routeButton.classList.add("state-in_progress");
|
||||
if (routeButton.dataset.b04RouteEnabled === "true") return;
|
||||
routeButton.dataset.b04RouteEnabled = "true";
|
||||
routeButton.addEventListener("click", () => {
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[2]);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatus(status: SurfaceStatusResponse | null): void {
|
||||
statusBox.replaceChildren();
|
||||
if (!status) {
|
||||
@@ -299,14 +318,34 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
type.textContent = model.model_type;
|
||||
const variant =
|
||||
model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral";
|
||||
head.append(type, createTag(model.status, variant));
|
||||
head.append(
|
||||
type,
|
||||
createTag(
|
||||
model.status === "CONFIRMED" ? L("B04_Surface_Model_Confirmed") : model.status,
|
||||
variant,
|
||||
),
|
||||
);
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b04-surface__model-meta";
|
||||
meta.append(
|
||||
buildInfoLine(L("B04_Surface_Model_Filter"), model.generation_params?.source_filter),
|
||||
buildInfoLine(
|
||||
L("B04_Surface_Model_Representation"),
|
||||
model.generation_params?.representation,
|
||||
),
|
||||
buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m),
|
||||
buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path),
|
||||
);
|
||||
card.append(head, meta);
|
||||
const confirmButton = createButton({
|
||||
label:
|
||||
model.status === "CONFIRMED"
|
||||
? L("B04_Surface_Model_Confirmed")
|
||||
: L("B04_Surface_Btn_Confirm"),
|
||||
variant: model.status === "CONFIRMED" ? "ghost" : "filled",
|
||||
onClick: () => void onB04_Surface_Confirm_Click(model),
|
||||
});
|
||||
confirmButton.disabled = model.status === "CONFIRMED";
|
||||
card.append(head, meta, confirmButton);
|
||||
modelList.append(card);
|
||||
}
|
||||
}
|
||||
@@ -343,6 +382,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
renderStatus(status);
|
||||
renderModels(models.models);
|
||||
renderGroundStats(stats.filters);
|
||||
mapViewer.render(projectId);
|
||||
try {
|
||||
viewer.render(await fetchSurfacePointCloud(projectId));
|
||||
} catch {
|
||||
@@ -350,6 +390,27 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Confirm_Click(model: SurfaceModelSummary): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await confirmSurfaceModel(projectId, model.id);
|
||||
const summary = L("B04_Surface_Confirm_Success")
|
||||
.replace("{filter}", String(model.generation_params?.source_filter ?? "-"))
|
||||
.replace("{method}", model.model_type)
|
||||
.replace("{smoothing}", String(model.generation_params?.representation ?? "-"));
|
||||
showToast(summary, "success");
|
||||
await loadProjectData(projectId);
|
||||
enableRouteStep(projectId);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed");
|
||||
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Analyze_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId || !selectedInputFile) return;
|
||||
|
||||
@@ -86,9 +86,9 @@
|
||||
|
||||
/* --- 우측 결과 영역 --- */
|
||||
.b04-surface__workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(360px, 1fr) auto;
|
||||
min-height: calc(100vh - var(--wf-header-height));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
}
|
||||
|
||||
.b04-surface__topbar {
|
||||
@@ -329,3 +329,142 @@
|
||||
height: 560px;
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
/* --- 하단 2D 지도 --- */
|
||||
.b04-map {
|
||||
--b04-map-vector: var(--color-accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
margin: var(--spacing-24);
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b04-map__header,
|
||||
.b04-map__controls,
|
||||
.b04-map__controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.b04-map__header {
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b04-map__header h3 {
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b04-map__controls {
|
||||
gap: var(--spacing-12);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b04-map__controls label {
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b04-map__controls select,
|
||||
.b04-map__controls button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b04-map__controls button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b04-map__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.b04-map__viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.b04-map__image,
|
||||
.b04-map__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.b04-map__image {
|
||||
object-fit: fill;
|
||||
transform-origin: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.b04-map__canvas {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-map__empty,
|
||||
.b04-map__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b04-map__empty {
|
||||
inset: 50% auto auto 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.b04-map__status {
|
||||
top: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b04-map__scale {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-16);
|
||||
left: var(--spacing-16);
|
||||
z-index: 2;
|
||||
height: var(--spacing-8);
|
||||
border: 2px solid var(--color-text);
|
||||
border-top: 0;
|
||||
color: var(--color-text);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b04-map__scale span {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-8);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.b04-map__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { fetchVWorldMeta, getVWorldMapUrl, fetchGisGeoJson } from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export interface SurfaceTerrainViewer {
|
||||
root: HTMLElement;
|
||||
@@ -596,15 +596,21 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
// Animation render loop
|
||||
let animationFrameId = 0;
|
||||
let hasConnected = false;
|
||||
function animate() {
|
||||
if (!root.isConnected) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
clearMesh();
|
||||
clearContours();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (!hasConnected) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
clearMesh();
|
||||
clearContours();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
hasConnected = true;
|
||||
controls.update();
|
||||
|
||||
// Render scale bar dynamically
|
||||
@@ -710,10 +716,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
currentModelsList = models;
|
||||
updateSelectedModel();
|
||||
},
|
||||
updateBgMap(projectId, bgLayer) {
|
||||
updateBgMap(_projectId, _bgLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
},
|
||||
updateGisLayer(projectId, gisLayer) {
|
||||
updateGisLayer(_projectId, _gisLayer) {
|
||||
// 포인트클라우드 뷰어와 인터페이스 조화를 위해 빈 껍데기 함수 정의
|
||||
},
|
||||
dispose() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { RENDER_OPTIONS } from "@config/config_frontend";
|
||||
import {
|
||||
fetchVWorldMeta,
|
||||
@@ -208,6 +209,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
let gisObjects: THREE.Object3D[] = [];
|
||||
let animationFrame = 0;
|
||||
let currentData: SurfacePointCloudSampleResponse | null = null;
|
||||
let hasConnected = false;
|
||||
|
||||
function resize(): void {
|
||||
const rect = viewerArea.getBoundingClientRect();
|
||||
@@ -233,12 +235,17 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
|
||||
function animate(): void {
|
||||
if (!root.isConnected) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (!hasConnected) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
} else {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
hasConnected = true;
|
||||
resize();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
@@ -558,7 +565,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
}
|
||||
|
||||
const getProjectIdLocal = () => {
|
||||
return localStorage.getItem("current_project_id");
|
||||
return localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
};
|
||||
|
||||
bgSelect.addEventListener("change", () => {
|
||||
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
type RoutePoint,
|
||||
type RouteSolveResponse,
|
||||
} from "./B05_wf2_Route_Api_Fetch";
|
||||
import {
|
||||
listSurfaceModels,
|
||||
type SurfaceModelSummary,
|
||||
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
|
||||
import "./B05_wf2_Route_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
@@ -77,6 +81,7 @@ function parseNumber(value: string): number | null {
|
||||
}
|
||||
|
||||
export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
let confirmedSurface: SurfaceModelSummary | null = null;
|
||||
/* ---- 좌측: 경로 제어점 ---- */
|
||||
const pointsGroup = document.createElement("fieldset");
|
||||
pointsGroup.className = "b05-route__group";
|
||||
@@ -124,12 +129,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
type: "text",
|
||||
value: "dtm",
|
||||
});
|
||||
const surfaceIdField = createInputField({
|
||||
label: L("B05_Route_Field_SurfaceId"),
|
||||
type: "number",
|
||||
min: 1,
|
||||
});
|
||||
surfaceGroup.append(surfaceLegend, filterField.root, methodField.root, surfaceIdField.root);
|
||||
filterField.input.readOnly = true;
|
||||
methodField.input.readOnly = true;
|
||||
const confirmedModelInfo = document.createElement("p");
|
||||
confirmedModelInfo.className = "b05-route__surface-info";
|
||||
confirmedModelInfo.textContent = L("B05_Route_Surface_NotConfirmed");
|
||||
surfaceGroup.append(surfaceLegend, confirmedModelInfo, filterField.root, methodField.root);
|
||||
|
||||
/* ---- 좌측: 설계 제약 ---- */
|
||||
const constraintGroup = document.createElement("fieldset");
|
||||
@@ -281,17 +286,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
async function onB05_Route_Solve_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
if (!confirmedSurface) {
|
||||
showToast(L("B05_Route_Surface_NotConfirmed"), "error");
|
||||
return;
|
||||
}
|
||||
const base = collectRequest();
|
||||
if (!base) return;
|
||||
|
||||
const surfaceId = parseNumber(surfaceIdField.input.value);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const result = await solveRoute(projectId, {
|
||||
...base,
|
||||
method: methodField.input.value.trim() || "dtm",
|
||||
smooth: smoothBox.checked,
|
||||
surface_model_id: surfaceId,
|
||||
surface_model_id: confirmedSurface.id,
|
||||
algorithm: algorithmSelect.select.value,
|
||||
grade_class: gradeSelect.select.value,
|
||||
max_uphill_grade: parseNumber(maxUphillField.input.value),
|
||||
@@ -329,9 +337,25 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (projectId) {
|
||||
try {
|
||||
workflowState = await fetchWorkflowState(projectId);
|
||||
const [nextWorkflowState, models] = await Promise.all([
|
||||
fetchWorkflowState(projectId),
|
||||
listSurfaceModels(projectId),
|
||||
]);
|
||||
workflowState = nextWorkflowState;
|
||||
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
||||
if (confirmedSurface) {
|
||||
const sourceFilter = confirmedSurface.generation_params?.source_filter;
|
||||
filterField.input.value = typeof sourceFilter === "string" ? sourceFilter : "";
|
||||
methodField.input.value = confirmedSurface.model_type;
|
||||
confirmedModelInfo.textContent = L("B05_Route_Surface_Confirmed")
|
||||
.replace("{id}", String(confirmedSurface.id))
|
||||
.replace("{method}", confirmedSurface.model_type);
|
||||
} else {
|
||||
solveButton.disabled = true;
|
||||
}
|
||||
} catch {
|
||||
/* 조회 실패 시 stages 미전달 → 전체 이동 허용 */
|
||||
solveButton.disabled = true;
|
||||
confirmedModelInfo.textContent = L("B05_Route_Surface_LoadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-route__surface-info {
|
||||
margin: 0;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* --- 체크박스 --- */
|
||||
.b05-route__check {
|
||||
display: flex;
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
/* =============================================================================
|
||||
* B10_Payment_UI_Page.ts
|
||||
* 로그인 후 10: 결재 페이지 (견적 확인 + 결재 진행)
|
||||
*
|
||||
* ⚠️ 본문(견적 요약/결재 수단) 준비 중 — 헤더/안내만 구성. 추후 구체화.
|
||||
* 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 스캐폴드 사용(§2).
|
||||
* ========================================================================== */
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import { section, table, text } from "@ui/ui_template_general_blocks";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import "./B10_Payment_UI_Style.css";
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingContent } from "../A00_Common/b_page_scaffold";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
export function renderB10Payment(root: HTMLElement): void {
|
||||
renderPendingContent(root, {
|
||||
const invoiceBody = document.createElement("div");
|
||||
invoiceBody.className = "b10-payment__summary";
|
||||
const invoiceDescription = document.createElement("p");
|
||||
invoiceDescription.textContent = L("B10_Payment_Invoice_Description");
|
||||
invoiceBody.append(invoiceDescription, createTag(L("B10_Payment_Invoice_Status"), "neutral"));
|
||||
|
||||
const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [
|
||||
createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }),
|
||||
]);
|
||||
|
||||
const depositNote = document.createElement("p");
|
||||
depositNote.className = "b10-payment__note";
|
||||
depositNote.textContent = L("B10_Payment_Deposit_Note");
|
||||
const depositBody = document.createElement("div");
|
||||
depositBody.className = "b10-payment__summary";
|
||||
depositBody.append(
|
||||
table(
|
||||
[],
|
||||
[
|
||||
[text(L("B10_Payment_Deposit_Account")), text(L("B10_Payment_Deposit_Pending"))],
|
||||
[text(L("B10_Payment_Deposit_Amount")), text(L("B10_Payment_Deposit_Pending"))],
|
||||
],
|
||||
),
|
||||
depositNote,
|
||||
);
|
||||
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b10-payment",
|
||||
title: L("B10_Payment_Title"),
|
||||
subtitle: L("B10_Payment_Subtitle"),
|
||||
content: [invoiceSection, section(L("B10_Payment_Deposit_Title"), depositBody, true)],
|
||||
});
|
||||
root.replaceChildren(layout.root);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.b10-payment__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b10-payment__note {
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -1,26 +1,51 @@
|
||||
/* =============================================================================
|
||||
* B11_Status_UI_Page.ts
|
||||
* 로그인 후 11: 상태 출력 페이지 (결재완료/문서생성/다운로드)
|
||||
*
|
||||
* ⚠️ 본문(진행 상태/결과물 다운로드) 준비 중 — 헤더/안내만 구성. 추후 구체화.
|
||||
* 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 스캐폴드 사용(§2).
|
||||
* ========================================================================== */
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import { section } from "@ui/ui_template_general_blocks";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import "./B11_Status_UI_Style.css";
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { renderPendingContent } from "../A00_Common/b_page_scaffold";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 페이지 진입점
|
||||
* -------------------------------------------------------------------------- */
|
||||
function buildStatusFlow(): HTMLElement {
|
||||
const flow = document.createElement("ol");
|
||||
flow.className = "b11-status__flow";
|
||||
const steps = [
|
||||
L("B11_Status_Step_Request"),
|
||||
L("B11_Status_Step_Issue"),
|
||||
L("B11_Status_Step_Deposit"),
|
||||
L("B11_Status_Step_Complete"),
|
||||
];
|
||||
for (const label of steps) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "b11-status__step";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = label;
|
||||
item.append(title, createTag(L("B11_Status_NotStarted"), "neutral"));
|
||||
flow.append(item);
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
|
||||
export function renderB11Status(root: HTMLElement): void {
|
||||
renderPendingContent(root, {
|
||||
const downloadBody = document.createElement("div");
|
||||
downloadBody.className = "b11-status__download";
|
||||
const description = document.createElement("p");
|
||||
description.textContent = L("B11_Status_Download_Description");
|
||||
downloadBody.append(
|
||||
description,
|
||||
createButton({ label: L("Common_Btn_Download"), variant: "ghost", disabled: true }),
|
||||
);
|
||||
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b11-status",
|
||||
title: L("B11_Status_Title"),
|
||||
subtitle: L("B11_Status_Subtitle"),
|
||||
content: [
|
||||
section(L("B11_Status_Flow_Title"), buildStatusFlow(), true),
|
||||
section(L("B11_Status_Download_Title"), downloadBody, true),
|
||||
],
|
||||
});
|
||||
root.replaceChildren(layout.root);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.b11-status__flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.b11-status__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12, var(--spacing-8));
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b11-status__download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b11-status__flow {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.b11-status__download {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ CREATE TABLE IF NOT EXISTS surface_models (
|
||||
model_type VARCHAR(50), -- dem_grid, tin, mesh_triangulated, contour_lines
|
||||
source_file_id INT, -- FK는 later
|
||||
processed_cloud_id INT, -- FK는 later
|
||||
status VARCHAR(50) DEFAULT 'PROCESSING', -- PROCESSING, COMPLETE, FAILED
|
||||
status VARCHAR(50) DEFAULT 'PROCESSING', -- PROCESSING, COMPLETE, CONFIRMED, FAILED
|
||||
crs_epsg INT,
|
||||
resolution_m FLOAT,
|
||||
model_file_path VARCHAR(500), -- storage/.../B04_wf1_Surface/models/...
|
||||
|
||||
@@ -50,29 +50,6 @@ CREATE TABLE IF NOT EXISTS system_resources (
|
||||
INDEX idx_system_resources_timestamp (timestamp)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_automations (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
project_id CHAR(36) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
logic_type VARCHAR(50) NOT NULL,
|
||||
config_json JSON NOT NULL,
|
||||
status ENUM('DRAFT', 'ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'DRAFT',
|
||||
created_by INT NOT NULL,
|
||||
updated_by INT NULL,
|
||||
last_executed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_project_automations_project_id (project_id),
|
||||
INDEX idx_project_automations_status (status),
|
||||
CONSTRAINT fk_project_automations_project_id FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_project_automations_created_by FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_project_automations_updated_by FOREIGN KEY (updated_by) REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
|
||||
ALTER TABLE join_requests
|
||||
ADD COLUMN IF NOT EXISTS review_comment TEXT NULL AFTER reviewed_at;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- "자동화 설계 로직" 기능 전면 제거 (2026-07-17)
|
||||
-- 앱 전체가 자동화 워크플로우(wf1~wf6)이므로 프로젝트별 자동화 로직 CRUD는 불필요.
|
||||
-- 004_dashboard.sql의 project_automations 정의도 함께 제거됨 (신규 설치는 애초에 생성 안 함).
|
||||
DROP TABLE IF EXISTS project_automations;
|
||||
+36
-26
@@ -35,8 +35,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/files_surface.md": {
|
||||
"mtime": 1784259351.9277234,
|
||||
"ast_hash": "7eb0461ec86ee4eae0ff72406e41f1cd",
|
||||
"mtime": 1784267731.9194815,
|
||||
"ast_hash": "7025d41c372c169b7929ed6baca3b07f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/logs_monitoring.md": {
|
||||
@@ -90,23 +90,23 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/ui_templates.md": {
|
||||
"mtime": 1784260037.317444,
|
||||
"ast_hash": "26b52705999f61d424a9060fd8509dcc",
|
||||
"mtime": 1784264934.0988722,
|
||||
"ast_hash": "6cb21cb237d90125d149b1db904a2d74",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/workflow_state.md": {
|
||||
"mtime": 1784259062.3032951,
|
||||
"ast_hash": "b5be92081d3d51e7aba4181ff3f7b677",
|
||||
"mtime": 1784267747.180053,
|
||||
"ast_hash": "c1fa859f4d19cea7059daf828b29a41e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/index.md": {
|
||||
"mtime": 1784260033.8125088,
|
||||
"ast_hash": "9a8f4621e4acf485cd1d9c4909e077de",
|
||||
"mtime": 1784267752.6659317,
|
||||
"ast_hash": "bc91a2dfed2319cb07c08e3d148d24ab",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/log.md": {
|
||||
"mtime": 1784259880.8909566,
|
||||
"ast_hash": "157d6ebdee9f0c5ccd727e6ebd506260",
|
||||
"mtime": 1784267757.9505198,
|
||||
"ast_hash": "5db722c00a166bd1bd486e71a0613148",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A01_Home/A01_components.md": {
|
||||
@@ -205,8 +205,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B01_Dashboard/B01_frontend.md": {
|
||||
"mtime": 1784259258.671021,
|
||||
"ast_hash": "817c78981729333cd1542b944f7758de",
|
||||
"mtime": 1784264944.284991,
|
||||
"ast_hash": "720d69c49dce55121af9846763aa3258",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B02_ProjRegister/B02_backend.md": {
|
||||
@@ -245,23 +245,23 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B03_FileInput/B03_frontend.md": {
|
||||
"mtime": 1784259161.700674,
|
||||
"ast_hash": "9d93af1df4e3350bb8775731e32ecc50",
|
||||
"mtime": 1784264939.1710696,
|
||||
"ast_hash": "ff5b954b7136ca7f73c842965240400c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_api.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "98dc9571c054a9cc67f41e34c924afc4",
|
||||
"mtime": 1784267047.0904605,
|
||||
"ast_hash": "5558d1f973ce53a60c263b62f71b9b8d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_backend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "0c5544f6648212b90660aa84f35982ba",
|
||||
"mtime": 1784267712.4147556,
|
||||
"ast_hash": "0de152bccf2967c5989e2ed9f0253781",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_db.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "7cb52c0844a907b14cfbd5230988c56f",
|
||||
"mtime": 1784267041.957495,
|
||||
"ast_hash": "50541e6a765c96c40cb34856acc29900",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_dependencies.md": {
|
||||
@@ -270,8 +270,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "5735fc2ab57c3965dcf50149b078fc93",
|
||||
"mtime": 1784267717.920956,
|
||||
"ast_hash": "4891c5490bbe949e6c7a7f709270dac8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_api.md": {
|
||||
@@ -295,8 +295,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "3a5bba842808d5a7a935882514495311",
|
||||
"mtime": 1784267722.8724308,
|
||||
"ast_hash": "f72755ed7acf8a7450feac8323fad91c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_api.md": {
|
||||
@@ -320,8 +320,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "98cdce5d523d5750350c1c8eeb41ae8e",
|
||||
"mtime": 1784264958.5574062,
|
||||
"ast_hash": "edc082e6e035ebea9f7427393c8c863f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B07_wf4_DesignDetail/B07_frontend.md": {
|
||||
@@ -348,5 +348,15 @@
|
||||
"mtime": 1784260030.4069157,
|
||||
"ast_hash": "85aec17d904c548e299a4e3810669cc0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B10_Payment/B10_frontend.md": {
|
||||
"mtime": 1784264923.3316023,
|
||||
"ast_hash": "922e565933ffcf90a3fa96307ae4dee3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B11_Status/B11_frontend.md": {
|
||||
"mtime": 1784264926.0308616,
|
||||
"ast_hash": "2cf11aa66d6346818c14c62fa8e2b12a",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import "./ui_template_general_layout.css";
|
||||
import { createButton, createCard } from "./ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "./ui_template_locale";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export function buildSectionHeader(
|
||||
titleText: string,
|
||||
actionButtons: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
const header = document.createElement("div");
|
||||
header.className = "ui-general-block__section-header b01-dashboard__section-header";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "ui-card__title";
|
||||
title.style.margin = "0";
|
||||
title.textContent = titleText;
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-general-block__actions b01-dashboard__actions";
|
||||
actions.append(...actionButtons);
|
||||
|
||||
header.append(title, actions);
|
||||
return header;
|
||||
}
|
||||
|
||||
export function section(
|
||||
title: string,
|
||||
body: HTMLElement,
|
||||
wide = false,
|
||||
actions: HTMLElement[] = [],
|
||||
): HTMLElement {
|
||||
const header = buildSectionHeader(title, actions);
|
||||
const card = createCard({ body: [header, body], raised: true });
|
||||
card.classList.add("ui-general-block__section", "b01-dashboard__section");
|
||||
if (wide) card.classList.add("ui-general-block__section--wide", "b01-dashboard__section--wide");
|
||||
return card;
|
||||
}
|
||||
|
||||
export function table(headers: string[], rows: HTMLElement[][]): HTMLElement {
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "ui-general-block__empty b01-dashboard__empty";
|
||||
empty.textContent = L("Common_Status_Empty");
|
||||
return empty;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ui-general-block__table-wrap b01-dashboard__table-wrap";
|
||||
const tableEl = document.createElement("table");
|
||||
tableEl.className = "ui-general-block__table b01-dashboard__table";
|
||||
const thead = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const header of headers) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = header;
|
||||
headRow.append(th);
|
||||
}
|
||||
thead.append(headRow);
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
for (const cell of row) {
|
||||
const td = document.createElement("td");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
tableEl.append(thead, tbody);
|
||||
wrap.append(tableEl);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function text(value: unknown): HTMLElement {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null || value === "" ? "-" : String(value);
|
||||
return span;
|
||||
}
|
||||
|
||||
export function actionPair(approve: () => void, reject: () => void): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ui-general-block__actions b01-dashboard__actions";
|
||||
row.append(
|
||||
createButton({ label: L("B01_Dashboard_Approve"), variant: "ghost", onClick: approve }),
|
||||
createButton({ label: L("B01_Dashboard_Reject"), variant: "danger", onClick: reject }),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.ui-general-layout {
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
padding: var(--spacing-40) var(--spacing-24) var(--spacing-48);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.ui-general-layout__inner {
|
||||
width: 100%;
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ui-general-layout__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-24);
|
||||
}
|
||||
|
||||
.ui-general-layout__title {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-heading-sm);
|
||||
}
|
||||
|
||||
.ui-general-layout__subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.ui-general-layout__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.ui-general-block__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.ui-general-block__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-general-block__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ui-general-block__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.ui-general-block__table th,
|
||||
.ui-general-block__table td {
|
||||
padding: var(--spacing-12, var(--spacing-8)) var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-general-block__empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ui-general-layout {
|
||||
padding: var(--spacing-24) var(--spacing-16) var(--spacing-40);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import "./ui_template_general_layout.css";
|
||||
|
||||
export interface GeneralLayoutOptions {
|
||||
pageClass?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
content: HTMLElement | readonly HTMLElement[];
|
||||
}
|
||||
|
||||
export interface GeneralLayoutHandle {
|
||||
root: HTMLElement;
|
||||
content: HTMLElement;
|
||||
}
|
||||
|
||||
export function createGeneralLayout(options: GeneralLayoutOptions): GeneralLayoutHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-general-layout";
|
||||
if (options.pageClass) root.classList.add(options.pageClass);
|
||||
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "ui-general-layout__inner";
|
||||
|
||||
if (options.title) {
|
||||
const header = document.createElement("header");
|
||||
header.className = "ui-general-layout__header";
|
||||
const title = document.createElement("h1");
|
||||
title.className = "ui-general-layout__title";
|
||||
title.textContent = options.title;
|
||||
header.append(title);
|
||||
if (options.subtitle) {
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "ui-general-layout__subtitle";
|
||||
subtitle.textContent = options.subtitle;
|
||||
header.append(subtitle);
|
||||
}
|
||||
inner.append(header);
|
||||
}
|
||||
|
||||
const content = document.createElement("main");
|
||||
content.className = "ui-general-layout__content";
|
||||
content.append(...(Array.isArray(options.content) ? options.content : [options.content]));
|
||||
inner.append(content);
|
||||
root.append(inner);
|
||||
return { root, content };
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export const ui_locales = {
|
||||
Common_Msg_UnsavedChanges: ["저장되지 않은 변경사항이 있습니다", "You have unsaved changes"],
|
||||
Common_Msg_RequiredField: ["필수 입력 항목입니다", "This field is required"],
|
||||
Common_Msg_InvalidValue: ["올바르지 않은 값입니다", "Invalid value"],
|
||||
Workflow_Progress_Title: ["진행단계", "Progress"],
|
||||
Workflow_Overlay_Collapse: ["패널 접기", "Collapse panel"],
|
||||
Workflow_Overlay_Expand: ["패널 펼치기", "Expand panel"],
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 공통 — 폼 / 검증
|
||||
@@ -632,6 +635,36 @@ export const ui_locales = {
|
||||
],
|
||||
B04_Surface_Model_Resolution: ["해상도(m)", "Resolution (m)"],
|
||||
B04_Surface_Model_Path: ["파일 경로", "File path"],
|
||||
B04_Surface_Model_Filter: ["지면 필터", "Ground filter"],
|
||||
B04_Surface_Model_Representation: ["표현 방식", "Representation"],
|
||||
B04_Surface_Model_Confirmed: ["확정됨", "Confirmed"],
|
||||
B04_Surface_Btn_Confirm: ["이 모델 확정", "Confirm this model"],
|
||||
B04_Surface_Confirm_Success: [
|
||||
"모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}",
|
||||
"Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}",
|
||||
],
|
||||
B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."],
|
||||
B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"],
|
||||
B04_Surface_Map_Background: ["배경 지도", "Basemap"],
|
||||
B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"],
|
||||
B04_Surface_Map_None: ["없음", "None"],
|
||||
B04_Surface_Map_Satellite: ["위성", "Satellite"],
|
||||
B04_Surface_Map_Hybrid: ["하이브리드", "Hybrid"],
|
||||
B04_Surface_Map_White: ["백지도", "White map"],
|
||||
B04_Surface_Map_Cadastral: ["연속지적도", "Cadastral map"],
|
||||
B04_Surface_Map_Water: ["수계망", "Water network"],
|
||||
B04_Surface_Map_Landslide: ["산사태위험등급", "Landslide risk"],
|
||||
B04_Surface_Map_Sigungu: ["시군구", "District boundary"],
|
||||
B04_Surface_Map_Eupmyeondong: ["읍면동", "Town boundary"],
|
||||
B04_Surface_Map_Reset: ["보기 초기화", "Reset view"],
|
||||
B04_Surface_Map_ImageAlt: ["VWorld 배경 지도", "VWorld basemap"],
|
||||
B04_Surface_Map_Empty: [
|
||||
"배경 지도 또는 GIS 레이어를 선택하세요.",
|
||||
"Select a basemap or GIS layer.",
|
||||
],
|
||||
B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."],
|
||||
B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"],
|
||||
B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."],
|
||||
B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
|
||||
B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."],
|
||||
B04_Surface_Error_Selection: [
|
||||
@@ -656,6 +689,15 @@ export const ui_locales = {
|
||||
B05_Route_Field_Filter: ["지면 필터", "Ground filter"],
|
||||
B05_Route_Field_Method: ["지표면 표현", "Surface method"],
|
||||
B05_Route_Field_SurfaceId: ["지표면 모델 ID", "Surface model ID"],
|
||||
B05_Route_Surface_Confirmed: ["확정 모델 #{id} · {method}", "Confirmed model #{id} · {method}"],
|
||||
B05_Route_Surface_NotConfirmed: [
|
||||
"WF1에서 지표면 모델을 확정하세요.",
|
||||
"Confirm a surface model in WF1.",
|
||||
],
|
||||
B05_Route_Surface_LoadFailed: [
|
||||
"확정 지표면 모델을 불러오지 못했습니다.",
|
||||
"Failed to load the confirmed surface model.",
|
||||
],
|
||||
B05_Route_Group_Constraints: ["설계 제약", "Design Constraints"],
|
||||
B05_Route_Field_GradeClass: ["임도 등급", "Road grade class"],
|
||||
B05_Route_Field_Algorithm: ["경로 알고리즘", "Route algorithm"],
|
||||
@@ -724,8 +766,23 @@ export const ui_locales = {
|
||||
/* --- B10_Payment 결재 --- */
|
||||
B10_Payment_Title: ["결재", "Payment"],
|
||||
B10_Payment_Subtitle: [
|
||||
"산출된 견적을 확인하고 결재를 진행하세요.",
|
||||
"Review the estimate and proceed with payment.",
|
||||
"세금계산서 발행과 계좌 입금 절차를 확인하세요.",
|
||||
"Review the tax invoice and bank transfer process.",
|
||||
],
|
||||
B10_Payment_Invoice_Title: ["세금계산서 발행 요청", "Tax Invoice Request"],
|
||||
B10_Payment_Invoice_Description: [
|
||||
"사업자 정보와 발행 금액은 견적 확정 후 연결됩니다.",
|
||||
"Business details and the invoice amount will be linked after the estimate is finalized.",
|
||||
],
|
||||
B10_Payment_Invoice_Request: ["발행 요청", "Request Invoice"],
|
||||
B10_Payment_Invoice_Status: ["요청 전", "Not Requested"],
|
||||
B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"],
|
||||
B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"],
|
||||
B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"],
|
||||
B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"],
|
||||
B10_Payment_Deposit_Note: [
|
||||
"입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.",
|
||||
"Design documents and DWG downloads are enabled after the deposit is confirmed.",
|
||||
],
|
||||
|
||||
/* --- B11_Status 상태 출력 --- */
|
||||
@@ -734,6 +791,17 @@ export const ui_locales = {
|
||||
"결재·문서 생성 상태를 확인하고 결과물을 내려받으세요.",
|
||||
"Check payment and document status, and download results.",
|
||||
],
|
||||
B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"],
|
||||
B11_Status_Step_Request: ["발행 요청", "Invoice Requested"],
|
||||
B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"],
|
||||
B11_Status_Step_Deposit: ["입금 확인", "Deposit Confirmed"],
|
||||
B11_Status_Step_Complete: ["완료", "Complete"],
|
||||
B11_Status_NotStarted: ["시작 전", "Not Started"],
|
||||
B11_Status_Download_Title: ["결과물 다운로드", "Result Downloads"],
|
||||
B11_Status_Download_Description: [
|
||||
"입금 확인이 완료되면 설계문서와 DWG 다운로드가 활성화됩니다.",
|
||||
"Design document and DWG downloads are enabled after deposit confirmation.",
|
||||
],
|
||||
|
||||
// 프로젝트 관리
|
||||
B01_Dashboard_EditProject: ["프로젝트 수정", "Edit Project"],
|
||||
@@ -746,13 +814,6 @@ export const ui_locales = {
|
||||
B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"],
|
||||
B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"],
|
||||
|
||||
// 자동화 로직
|
||||
B01_Dashboard_AutomationLogic: ["자동화 설계 로직", "Automation Logic"],
|
||||
B01_Dashboard_CreateAutomation: ["자동화 생성", "Create Automation"],
|
||||
B01_Dashboard_EditAutomation: ["자동화 수정", "Edit Automation"],
|
||||
B01_Dashboard_DeleteAutomation: ["자동화 삭제", "Delete Automation"],
|
||||
B01_Dashboard_ExecuteAutomation: ["자동화 실행", "Execute Automation"],
|
||||
|
||||
// 확인 메시지
|
||||
B01_Dashboard_Confirm_DeleteProject: [
|
||||
"프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
.ui-workflow-overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__panel {
|
||||
position: fixed;
|
||||
top: calc(var(--spacing-64) + var(--spacing-16));
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - var(--spacing-64) - var(--spacing-32));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* title 패널: 헤더 아래 전체 높이 도킹 사이드바 (열림: 본문 밀기 / 닫힘: 좌측으로 슬라이드 아웃) */
|
||||
.ui-workflow-overlay__panel--title {
|
||||
top: var(--spacing-64);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48)));
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
border-radius: 0;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__panel--title.is-collapsed {
|
||||
transform: translateX(-100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__panel--title .ui-workflow-overlay__body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 닫힘 전환 중에도 내용이 함께 슬라이드되도록 body를 숨기지 않음 */
|
||||
.ui-workflow-overlay__panel--title.is-collapsed .ui-workflow-overlay__body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__panel--progress {
|
||||
right: var(--spacing-24);
|
||||
width: calc(var(--wf-left-panel-width) / 2);
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-12, var(--spacing-8));
|
||||
min-height: var(--spacing-48);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__toggle {
|
||||
flex: 0 0 auto;
|
||||
width: var(--spacing-32);
|
||||
height: var(--spacing-32);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* title 패널 우측 가장자리 세로 중앙 핸들 — 닫히면 이 버튼만 화면 왼쪽에 남음 */
|
||||
.ui-workflow-overlay__handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: calc(-1 * var(--spacing-24));
|
||||
width: var(--spacing-24);
|
||||
height: var(--spacing-64);
|
||||
transform: translateY(-50%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 0;
|
||||
border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__body {
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--spacing-16) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__panel.is-collapsed .ui-workflow-overlay__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-workflow-overlay__panel.is-collapsed .ui-workflow-overlay__header {
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.ui-workflow-overlay__panel--progress {
|
||||
top: calc(var(--spacing-64) + var(--spacing-8));
|
||||
right: var(--spacing-8);
|
||||
max-height: calc(100vh - var(--spacing-64) - var(--spacing-16));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import "./ui_template_overlay.css";
|
||||
import { t } from "./ui_template_locale";
|
||||
|
||||
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
|
||||
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
|
||||
|
||||
export interface WorkflowOverlayOptions {
|
||||
title: string;
|
||||
progressContent: HTMLElement;
|
||||
optionsContent?: HTMLElement;
|
||||
showTitlePanel?: boolean;
|
||||
onOptionsOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface WorkflowOverlayHandle {
|
||||
root: HTMLElement;
|
||||
setTitleOpen: (isOpen: boolean) => void;
|
||||
setProgressOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function readOpenState(key: string): boolean {
|
||||
return sessionStorage.getItem(key) !== "false";
|
||||
}
|
||||
|
||||
function createPanel(
|
||||
variant: "title" | "progress",
|
||||
titleText: string,
|
||||
body: HTMLElement,
|
||||
storageKey: string,
|
||||
onOpenChange?: (isOpen: boolean) => void,
|
||||
): { root: HTMLElement; setOpen: (isOpen: boolean) => void } {
|
||||
const root = document.createElement("aside");
|
||||
root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`;
|
||||
|
||||
// title 패널은 사이드바 도킹형 — 토글이 패널 우측 가장자리 세로 중앙 핸들로 나감
|
||||
const isSidebar = variant === "title";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "ui-workflow-overlay__header";
|
||||
const title = document.createElement("h2");
|
||||
title.className = "ui-workflow-overlay__title";
|
||||
title.textContent = titleText;
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = isSidebar ? "ui-workflow-overlay__handle" : "ui-workflow-overlay__toggle";
|
||||
|
||||
if (isSidebar) {
|
||||
header.append(title);
|
||||
root.append(header, toggle);
|
||||
} else {
|
||||
header.append(title, toggle);
|
||||
root.append(header);
|
||||
}
|
||||
if (body.childElementCount > 0) {
|
||||
body.classList.add("ui-workflow-overlay__body");
|
||||
root.append(body);
|
||||
}
|
||||
|
||||
function setOpen(isOpen: boolean): void {
|
||||
root.classList.toggle("is-collapsed", !isOpen);
|
||||
toggle.textContent = isSidebar ? (isOpen ? "◀" : "▶") : isOpen ? "−" : "+";
|
||||
toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand");
|
||||
toggle.setAttribute("aria-label", toggle.title);
|
||||
toggle.setAttribute("aria-expanded", String(isOpen));
|
||||
sessionStorage.setItem(storageKey, String(isOpen));
|
||||
onOpenChange?.(isOpen);
|
||||
}
|
||||
|
||||
function toggleOpen(): void {
|
||||
setOpen(root.classList.contains("is-collapsed"));
|
||||
}
|
||||
|
||||
title.tabIndex = 0;
|
||||
title.setAttribute("role", "button");
|
||||
title.addEventListener("click", toggleOpen);
|
||||
title.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggleOpen();
|
||||
}
|
||||
});
|
||||
toggle.addEventListener("click", toggleOpen);
|
||||
setOpen(readOpenState(storageKey));
|
||||
return { root, setOpen };
|
||||
}
|
||||
|
||||
export function createWorkflowOverlays(options: WorkflowOverlayOptions): WorkflowOverlayHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-workflow-overlay";
|
||||
|
||||
const progressBody = document.createElement("div");
|
||||
progressBody.append(options.progressContent);
|
||||
|
||||
const progressPanel = createPanel(
|
||||
"progress",
|
||||
t("Workflow_Progress_Title"),
|
||||
progressBody,
|
||||
PROGRESS_OVERLAY_STATE_KEY,
|
||||
);
|
||||
let setTitleOpen = (_isOpen: boolean): void => {};
|
||||
if (options.showTitlePanel !== false) {
|
||||
const titleBody = document.createElement("div");
|
||||
if (options.optionsContent) titleBody.append(options.optionsContent);
|
||||
const titlePanel = createPanel(
|
||||
"title",
|
||||
options.title,
|
||||
titleBody,
|
||||
TITLE_OVERLAY_STATE_KEY,
|
||||
options.onOptionsOpenChange,
|
||||
);
|
||||
root.append(titlePanel.root);
|
||||
setTitleOpen = titlePanel.setOpen;
|
||||
}
|
||||
root.append(progressPanel.root);
|
||||
|
||||
return {
|
||||
root,
|
||||
setTitleOpen,
|
||||
setProgressOpen: progressPanel.setOpen,
|
||||
};
|
||||
}
|
||||
@@ -1,92 +1,9 @@
|
||||
.ui-workflow-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--wf-header-height));
|
||||
position: relative;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
min-height: var(--wf-header-height);
|
||||
padding: var(--spacing-8) var(--spacing-24);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__menu-button {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: var(--spacing-24);
|
||||
z-index: var(--z-dropdown);
|
||||
width: auto;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__header-hide {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* 헤더 슬라이드업 숨김 */
|
||||
.ui-workflow-layout__header {
|
||||
transition: margin-top var(--transition-base);
|
||||
}
|
||||
|
||||
.ui-workflow-layout.is-header-hidden .ui-workflow-layout__header {
|
||||
margin-top: calc(-1 * var(--wf-header-height));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 헤더 복원 화살표: 숨김 상태에서만 표시 */
|
||||
.ui-workflow-layout__header-reveal {
|
||||
display: none;
|
||||
align-self: center;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
margin: var(--spacing-4) auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-workflow-layout.is-header-hidden .ui-workflow-layout__header-reveal {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-24);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__title {
|
||||
font-size: var(--text-subheading);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__steps {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
@@ -99,14 +16,19 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__steps.is-vertical {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__step {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--spacing-4) var(--spacing-16);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
border: 1px solid transparent;
|
||||
cursor: default;
|
||||
transition:
|
||||
background-color var(--transition-base),
|
||||
@@ -114,17 +36,39 @@
|
||||
cursor var(--transition-base);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__steps.is-vertical .ui-workflow-layout__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
min-height: var(--spacing-40);
|
||||
border-radius: var(--radius-buttons);
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__step-icon {
|
||||
flex: 0 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__step-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__step.is-enabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-workflow-layout__step.is-enabled:hover {
|
||||
background: var(--color-paper-hover, var(--color-paper));
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__steps.is-compact .ui-workflow-layout__step {
|
||||
height: 28px;
|
||||
padding: 0 var(--spacing-12);
|
||||
padding: 0 var(--spacing-12, var(--spacing-8));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -156,58 +100,25 @@
|
||||
}
|
||||
|
||||
.ui-workflow-layout__body {
|
||||
position: relative;
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
transition: padding-left var(--transition-base);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__dropdown-panel {
|
||||
position: absolute;
|
||||
top: 108px;
|
||||
left: var(--spacing-24);
|
||||
width: 320px;
|
||||
max-height: calc(100vh - var(--wf-header-height) - 80px);
|
||||
z-index: var(--z-dropdown);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--spacing-16);
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--transition-base),
|
||||
transform var(--transition-base);
|
||||
/* 좌측 옵션 사이드바 열림 시 상세화면을 오른쪽으로 밀어냄 */
|
||||
.ui-workflow-layout.is-options-open .ui-workflow-layout__body {
|
||||
padding-left: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48)));
|
||||
}
|
||||
|
||||
.ui-workflow-layout.is-menu-open .ui-workflow-layout__dropdown-panel {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
@media (max-width: 860px) {
|
||||
.ui-workflow-layout.is-options-open .ui-workflow-layout__body {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-workflow-layout__main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
min-height: calc(100vh - var(--spacing-64));
|
||||
overflow: auto;
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.ui-workflow-layout__title-wrap {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-workflow-layout__dropdown-panel {
|
||||
left: var(--spacing-8);
|
||||
right: var(--spacing-8);
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./ui_template_workflow_layout.css";
|
||||
import { t } from "./ui_template_locale";
|
||||
import { createWorkflowOverlays } from "./ui_template_overlay";
|
||||
|
||||
export interface WorkflowStage {
|
||||
stage_no: number;
|
||||
@@ -11,20 +12,18 @@ export interface WorkflowLayoutOptions {
|
||||
title: string;
|
||||
steps: string[];
|
||||
activeStep: number;
|
||||
leftPanel: HTMLElement;
|
||||
leftPanel?: HTMLElement;
|
||||
mainContent: HTMLElement;
|
||||
stages?: WorkflowStage[];
|
||||
currentStage?: number;
|
||||
routes?: readonly string[];
|
||||
onStepClick?: (stepIndex: number, route: string) => void;
|
||||
onMenuToggle?: (isOpen: boolean) => void;
|
||||
onHeaderToggle?: (isVisible: boolean) => void;
|
||||
}
|
||||
|
||||
export interface WorkflowLayoutHandle {
|
||||
root: HTMLElement;
|
||||
setMenuOpen: (isOpen: boolean) => void;
|
||||
setHeaderVisible: (isVisible: boolean) => void;
|
||||
setOptionsOpen: (isOpen: boolean) => void;
|
||||
setProgressOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export interface StepBarOptions {
|
||||
@@ -32,9 +31,13 @@ export interface StepBarOptions {
|
||||
currentStage?: number;
|
||||
routes?: readonly string[];
|
||||
compact?: boolean;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
icons?: readonly string[];
|
||||
onStepClick?: (stepIndex: number, route: string) => void;
|
||||
}
|
||||
|
||||
export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "🏗️", "∑", "📄"] as const;
|
||||
|
||||
export function createStepBar(
|
||||
steps: readonly string[],
|
||||
activeStep: number,
|
||||
@@ -43,18 +46,31 @@ export function createStepBar(
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "ui-workflow-layout__steps";
|
||||
if (options?.compact) bar.classList.add("is-compact");
|
||||
if (options?.orientation === "vertical") bar.classList.add("is-vertical");
|
||||
steps.forEach((step, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-workflow-layout__step";
|
||||
if (index === activeStep) button.classList.add("is-active");
|
||||
button.textContent = step;
|
||||
const iconText = options?.icons?.[index];
|
||||
if (iconText) {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "ui-workflow-layout__step-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = iconText;
|
||||
const label = document.createElement("span");
|
||||
label.className = "ui-workflow-layout__step-label";
|
||||
label.textContent = step;
|
||||
button.append(icon, label);
|
||||
} else {
|
||||
button.textContent = step;
|
||||
}
|
||||
|
||||
const stage =
|
||||
options?.stages?.find((item) => item.stage_no === index) ?? options?.stages?.[index];
|
||||
const hasStages = Boolean(options?.stages?.length);
|
||||
const isEnabled =
|
||||
!hasStages || stage?.state !== "NOT_STARTED" || stage?.stage_no === options?.currentStage;
|
||||
const isBlockedState = stage?.state === "NOT_STARTED" || stage?.state === "STALE";
|
||||
const isEnabled = !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage;
|
||||
button.classList.toggle("is-enabled", isEnabled);
|
||||
button.disabled = !isEnabled;
|
||||
|
||||
@@ -90,95 +106,33 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-workflow-layout";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "ui-workflow-layout__header";
|
||||
|
||||
const menuButton = document.createElement("button");
|
||||
menuButton.type = "button";
|
||||
menuButton.className = "ui-workflow-layout__menu-button";
|
||||
menuButton.setAttribute("aria-label", "Toggle settings panel");
|
||||
menuButton.innerHTML = "🛠️ <span>Option ▾</span>";
|
||||
|
||||
const titleWrap = document.createElement("div");
|
||||
titleWrap.className = "ui-workflow-layout__title-wrap";
|
||||
const title = document.createElement("h2");
|
||||
title.className = "ui-workflow-layout__title";
|
||||
title.textContent = options.title;
|
||||
titleWrap.append(
|
||||
title,
|
||||
createStepBar(options.steps, options.activeStep, {
|
||||
stages: options.stages,
|
||||
currentStage: options.currentStage,
|
||||
routes: options.routes,
|
||||
onStepClick: options.onStepClick,
|
||||
}),
|
||||
);
|
||||
|
||||
const headerHideButton = document.createElement("button");
|
||||
headerHideButton.type = "button";
|
||||
headerHideButton.className = "ui-workflow-layout__header-hide";
|
||||
headerHideButton.setAttribute("aria-label", "Hide header");
|
||||
headerHideButton.textContent = "▲";
|
||||
|
||||
header.append(titleWrap, headerHideButton);
|
||||
|
||||
// 헤더 숨김 시 표시되는 복원 화살표
|
||||
const headerRevealButton = document.createElement("button");
|
||||
headerRevealButton.type = "button";
|
||||
headerRevealButton.className = "ui-workflow-layout__header-reveal";
|
||||
headerRevealButton.setAttribute("aria-label", "Show header");
|
||||
headerRevealButton.textContent = "▼";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "ui-workflow-layout__body";
|
||||
|
||||
const dropdownPanel = document.createElement("div");
|
||||
dropdownPanel.className = "ui-workflow-layout__dropdown-panel";
|
||||
dropdownPanel.append(options.leftPanel);
|
||||
|
||||
const main = document.createElement("main");
|
||||
main.className = "ui-workflow-layout__main";
|
||||
main.style.position = "relative";
|
||||
main.append(menuButton, options.mainContent);
|
||||
main.append(options.mainContent);
|
||||
body.append(main);
|
||||
|
||||
body.append(dropdownPanel, main);
|
||||
root.append(header, headerRevealButton, body);
|
||||
|
||||
function setMenuOpen(isOpen: boolean): void {
|
||||
root.classList.toggle("is-menu-open", isOpen);
|
||||
menuButton.setAttribute("aria-expanded", String(isOpen));
|
||||
const icon = menuButton.querySelector("span");
|
||||
if (icon) {
|
||||
icon.textContent = isOpen ? "Option ▴" : "Option ▾";
|
||||
}
|
||||
options.onMenuToggle?.(isOpen);
|
||||
}
|
||||
|
||||
function setHeaderVisible(isVisible: boolean): void {
|
||||
root.classList.toggle("is-header-hidden", !isVisible);
|
||||
headerHideButton.setAttribute("aria-expanded", String(isVisible));
|
||||
options.onHeaderToggle?.(isVisible);
|
||||
}
|
||||
|
||||
menuButton.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(!root.classList.contains("is-menu-open"));
|
||||
const progressContent = createStepBar(options.steps, options.activeStep, {
|
||||
stages: options.stages,
|
||||
currentStage: options.currentStage,
|
||||
routes: options.routes,
|
||||
orientation: "vertical",
|
||||
icons: WORKFLOW_STEP_ICONS,
|
||||
onStepClick: options.onStepClick,
|
||||
});
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: options.title,
|
||||
optionsContent: options.leftPanel,
|
||||
progressContent,
|
||||
onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen),
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (root.classList.contains("is-menu-open")) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!dropdownPanel.contains(target) && !menuButton.contains(target)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
headerHideButton.addEventListener("click", () => setHeaderVisible(false));
|
||||
headerRevealButton.addEventListener("click", () => setHeaderVisible(true));
|
||||
|
||||
setMenuOpen(false);
|
||||
setHeaderVisible(true);
|
||||
|
||||
return { root, setMenuOpen, setHeaderVisible };
|
||||
root.append(body, overlays.root);
|
||||
return {
|
||||
root,
|
||||
setOptionsOpen: overlays.setTitleOpen,
|
||||
setProgressOpen: overlays.setProgressOpen,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user