This commit is contained in:
2026-07-17 15:11:31 +09:00
parent 4ac681bd10
commit cf459f97a6
41 changed files with 1686 additions and 1026 deletions
-1
View File
@@ -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,
-51
View File
@@ -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",
-111
View File
@@ -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
-93
View File
@@ -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"}
-7
View File
@@ -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 -1
View File
@@ -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 -94
View File
@@ -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 {
+2 -1
View File
@@ -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");
-7
View File
@@ -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);
}
-139
View File
@@ -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);
});
}
+2 -1
View File
@@ -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,
+4 -16
View File
@@ -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),
+31 -149
View File
@@ -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);
+139
View File
@@ -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> {
+57 -12
View File
@@ -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("확정할 지표면 모델을 찾을 수 없습니다.")
+46 -3
View File
@@ -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 입력 파일 목록을 조회한다."""
+18
View File
@@ -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();
},
};
}
+64 -3
View File
@@ -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;
+142 -3
View File
@@ -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() {
+12 -5
View File
@@ -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", () => {
+34 -10
View File
@@ -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");
}
}
+7
View File
@@ -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;
+34 -15
View File
@@ -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);
}
+14
View File
@@ -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);
}
+40 -15
View File
@@ -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);
}
+38
View File
@@ -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;
}
}
+1 -1
View File
@@ -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/...
-23
View File
@@ -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
View File
@@ -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": ""
}
}
+90
View File
@@ -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);
}
}
+45
View File
@@ -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 };
}
+70 -9
View File
@@ -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: [
"프로젝트를 삭제하시겠습니까? 되돌릴 수 없습니다.",
+122
View File
@@ -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));
}
}
+121
View File
@@ -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,
};
}
+42 -131
View File
@@ -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;
}
}
+45 -91
View File
@@ -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,
};
}