diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index 2a3ff1ef..d527086f 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -1,18 +1,25 @@ /* ============================================================================= * b_page_scaffold.ts - * B 그룹 페이지 공통 스캐폴드 — "헤더 + 준비 중 안내" 및 워크플로우 셸 래퍼 + * B 그룹 페이지 공통 스캐폴드 — "헤더 + 준비 중 안내" 및 워크플로우 레이아웃 래퍼 * * B03~B06 등 본문 미구현 페이지에서 헤더/푸터(공통 셸)를 제외한 콘텐츠 영역을 * 일관된 형태로 채우기 위한 헬퍼. 실제 본문은 0_old 참고하여 추후 구체화. * * 제약 준수 (frontend.md): * - 문구는 호출측에서 locale 참조 후 문자열로 전달 (§3). - * - 공통 워크플로우 셸(createWorkflowShell) 재사용 (§2 3단 레이아웃). + * - 공통 워크플로우 레이아웃(createWorkflowLayout) 재사용 (§2 3단 레이아웃). * - 색상/간격은 theme.css 변수만 사용 (§1). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createWorkflowShell } from "@ui/ui_template_elements"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, + type WorkflowState, +} from "./b_workflow_nav"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -77,20 +84,35 @@ export interface PendingWorkflowOptions { activeStep: number; } -export function renderPendingWorkflow(root: HTMLElement, opts: PendingWorkflowOptions): void { +export async function renderPendingWorkflow( + root: HTMLElement, + opts: PendingWorkflowOptions, +): Promise { injectScaffoldStyles(); - const shell = createWorkflowShell({ + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + let workflowState: WorkflowState | undefined; + if (projectId) { + try { + workflowState = await fetchWorkflowState(projectId); + } catch { + /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ + } + } + const layout = createWorkflowLayout({ title: opts.title, steps: opts.steps, activeStep: opts.activeStep, + leftPanel: buildPendingBlock(), + mainContent: buildPendingBlock(), + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, }); - shell.root.classList.add("b-scaffold-wf"); - - // 좌측/우측 모두 준비 중 안내로 채움 (본문은 추후 구체화) - shell.leftPanel.append(buildPendingBlock()); - shell.rightArea.append(buildPendingBlock()); - - root.append(shell.root); + layout.root.classList.add("b-scaffold-wf"); + root.append(layout.root); } /** 워크플로우 스텝 라벨 7종 (locale 결과) — B03~B09 공용. diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts new file mode 100644 index 00000000..fb0ee940 --- /dev/null +++ b/A00_Common/b_workflow_nav.ts @@ -0,0 +1,40 @@ +import { + API_BASE_URL, + CURRENT_PROJECT_ID_KEY, + ROUTES, + type RoutePath, +} from "@config/config_frontend"; +import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { navigateTo } from "./router"; + +export interface WorkflowState { + project_id: string; + current_stage: number; + stages: WorkflowStage[]; +} + +export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [ + ROUTES.B03_FILE_INPUT, + ROUTES.B04_WF1_SURFACE, + ROUTES.B05_WF2_ROUTE, + ROUTES.B06_WF3_PROFILE_CROSS, + ROUTES.B07_WF4_DESIGN_DETAIL, + ROUTES.B08_WF5_QUANTITY, + ROUTES.B09_WF6_ESTIMATION, +]; + +export async function fetchWorkflowState(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Workflow state request failed: ${response.status}`); + } + const data = await response.json(); + return data.workflow_state ?? data; +} + +export function goToWorkflowStage(projectId: string, route: RoutePath): void { + localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + navigateTo(route); +} diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index 5eb1fb13..b653ead5 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -8,9 +8,13 @@ from fastapi.responses import JSONResponse from common_util.common_util_auth import ( create_session, delete_session_cookie, + generate_device_token, generate_otp, + get_device_token_cookie, + hash_device_token, hash_password, hash_user_agent, + set_device_token_cookie, set_session_cookie, verify_password, verify_session, @@ -22,11 +26,11 @@ from common_util.common_util_auth_repository import ( delete_session, get_active_otp, get_user_by_email, - has_known_browser, + has_trusted_device, record_failed_login, record_login, replace_otp, - trust_browser, + trust_device, ) from common_util.common_util_email import send_email_background from common_util.common_util_email_templates import otp_email, security_alert_email @@ -74,27 +78,38 @@ async def request_login(payload: LoginRequest, request: Request): periodic_reverify = ( user["last_email_verified_at"] is None or user["last_email_verified_at"] < reverify_before ) - new_browser = not await has_known_browser(user["id"], hash_user_agent(agent)) + device_token = get_device_token_cookie(request) + device_token_hash = hash_device_token(device_token) if device_token else None + trusted_device = bool( + device_token_hash and await has_trusted_device(user["id"], device_token_hash) + ) + new_browser = not trusted_device if periodic_reverify or new_browser: await _send_otp(user, "LOGIN", "로그인") return { "status": "otp_required", "reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER", } - return await _finish_login(user, agent) + return await _finish_login(user, agent, device_token_hash) -async def _finish_login(user: dict, agent: str) -> JSONResponse: +async def _finish_login( + user: dict, agent: str, previous_device_token_hash: str | None = None +) -> JSONResponse: await clear_login_failures(user["id"]) - await trust_browser(user["id"], hash_user_agent(agent)) + device_token = generate_device_token() + await trust_device( + user["id"], + hash_device_token(device_token), + hash_user_agent(agent), + previous_device_token_hash, + ) from config.config_db import get_db_pool pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( - """UPDATE users SET last_login = CURRENT_TIMESTAMP, - auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) - WHERE id = %s""", + "UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = %s", (user["id"],), ) await connection.commit() @@ -106,6 +121,7 @@ async def _finish_login(user: dict, agent: str) -> JSONResponse: "user": {"id": user["id"], "email": user["email"], "role": user["role"]}, } ) + set_device_token_cookie(response, device_token) set_session_cookie(response, session_id) return response @@ -139,7 +155,9 @@ async def verify_login(payload: OtpVerifyRequest, request: Request): (user["id"],), ) await connection.commit() - return await _finish_login(user, _user_agent(request)) + device_token = get_device_token_cookie(request) + previous_device_token_hash = hash_device_token(device_token) if device_token else None + return await _finish_login(user, _user_agent(request), previous_device_token_hash) @router.get("/session") diff --git a/A06_Login/A06_Login_UI_Auth_Page.ts b/A06_Login/A06_Login_UI_Auth_Page.ts index 13dd7cba..2b5f9e9a 100644 --- a/A06_Login/A06_Login_UI_Auth_Page.ts +++ b/A06_Login/A06_Login_UI_Auth_Page.ts @@ -29,7 +29,96 @@ export function renderA06Login(root: HTMLElement): void { const submit = createButton({ label: L("A06_Login_Submit"), type: "submit" }); const form = document.createElement("form"); form.className = "a06-login__form"; + const otpActions = document.createElement("div"); + otpActions.className = "a06-login__otp-actions"; + otpActions.hidden = true; + const resend = createButton({ + label: L("A06_Login_OtpResend"), + variant: "ghost", + onClick: onA06_Login_OtpResend_Click, + }); + const back = createButton({ + label: L("A06_Login_OtpBack"), + variant: "ghost", + onClick: onA06_Login_Back_Click, + }); + otpActions.append(resend, back); let otpRequired = false; + let resendSeconds = 0; + let resendTimer: number | undefined; + + function setButtonLabel(button: HTMLButtonElement, label: string): void { + button.querySelector(".ui-btn__label")!.textContent = label; + } + + function stopResendCooldown(): void { + if (resendTimer !== undefined) window.clearInterval(resendTimer); + resendTimer = undefined; + resendSeconds = 0; + resend.disabled = false; + setButtonLabel(resend, L("A06_Login_OtpResend")); + } + + function startResendCooldown(): void { + stopResendCooldown(); + resendSeconds = 60; + resend.disabled = true; + const updateLabel = (): void => { + setButtonLabel( + resend, + L("A06_Login_OtpResendCountdown").replace("{seconds}", String(resendSeconds)), + ); + }; + updateLabel(); + resendTimer = window.setInterval(() => { + resendSeconds -= 1; + if (resendSeconds <= 0) { + stopResendCooldown(); + return; + } + updateLabel(); + }, 1000); + } + + function showOtpStep(): void { + otpRequired = true; + email.input.disabled = true; + otp.root.hidden = false; + otpActions.hidden = false; + password.root.hidden = true; + setButtonLabel(submit, L("A06_Login_Verify")); + startResendCooldown(); + } + + function onA06_Login_Back_Click(): void { + otpRequired = false; + email.input.disabled = false; + password.root.hidden = false; + otp.root.hidden = true; + otp.input.value = ""; + otpActions.hidden = true; + setButtonLabel(submit, L("A06_Login_Submit")); + stopResendCooldown(); + } + + async function onA06_Login_OtpResend_Click(): Promise { + if (resend.disabled) return; + showLoadingOverlay(); + try { + const result = await requestLogin(email.input.value.trim(), password.input.value); + if (result.status === "otp_required") { + showToast(L("A06_Login_OtpSent"), "info"); + startResendCooldown(); + } else { + showToast(L("A06_Login_Success"), "success"); + navigateTo(ROUTES.B01_ACCOUNT); + } + } catch (error) { + showToast(error instanceof Error ? error.message : L("A06_Login_Error_Request"), "error"); + } finally { + hideLoadingOverlay(); + } + } async function onA06_Login_Submit_Click(event: Event): Promise { event.preventDefault(); @@ -51,10 +140,7 @@ export function renderA06Login(root: HTMLElement): void { ? await verifyLogin(emailValue, otp.input.value) : await requestLogin(emailValue, password.input.value); if (result.status === "otp_required") { - otpRequired = true; - otp.root.hidden = false; - password.root.hidden = true; - submit.querySelector(".ui-btn__label")!.textContent = L("A06_Login_Verify"); + showOtpStep(); showToast(L("A06_Login_OtpSent"), "info"); } else { showToast(L("A06_Login_Success"), "success"); @@ -68,7 +154,7 @@ export function renderA06Login(root: HTMLElement): void { } form.addEventListener("submit", onA06_Login_Submit_Click); - form.append(email.root, password.root, otp.root, submit); + form.append(email.root, password.root, otp.root, otpActions, submit); const register = createButton({ label: L("A06_Login_ToRegister"), variant: "ghost", diff --git a/A06_Login/A06_Login_UI_Style.css b/A06_Login/A06_Login_UI_Style.css index 7b7dd84d..b88717ea 100644 --- a/A06_Login/A06_Login_UI_Style.css +++ b/A06_Login/A06_Login_UI_Style.css @@ -42,6 +42,14 @@ flex-direction: column; gap: var(--spacing-16); } +.a06-login__otp-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} +.a06-login__otp-actions > .ui-btn { + flex: 1; +} .a06-login__submit { width: 100%; margin-top: var(--spacing-8); diff --git a/A07_Register/A07_Register_Router.py b/A07_Register/A07_Register_Router.py index 4ceef3bd..ca2d5b7b 100644 --- a/A07_Register/A07_Register_Router.py +++ b/A07_Register/A07_Register_Router.py @@ -3,11 +3,15 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse from common_util.common_util_auth import ( + generate_device_token, generate_otp, + hash_device_token, hash_password, hash_user_agent, + set_device_token_cookie, verify_password, ) from common_util.common_util_auth_repository import ( @@ -19,7 +23,7 @@ from common_util.common_util_auth_repository import ( refresh_pending_registration, replace_otp, search_companies, - trust_browser, + trust_device, ) from common_util.common_util_email import send_email_background from common_util.common_util_email_templates import otp_email @@ -75,10 +79,15 @@ async def verify_registration(payload: RegisterVerifyRequest, request: Request): await complete_registration(user["id"], bool(user["is_master"])) # 가입 인증한 브라우저를 신뢰 등록하여 첫 로그인 시 OTP를 생략한다. agent = request.headers.get("user-agent", "unknown")[:1000] - await trust_browser(user["id"], hash_user_agent(agent)) + device_token = generate_device_token() + await trust_device(user["id"], hash_device_token(device_token), hash_user_agent(agent)) # 회원가입 시점에는 회사가 없으므로(NO_COMPANY) 마스터 알림은 발송하지 않는다. # 회사 생성/연결은 로그인 후 B01_Dashboard에서 진행한다. - return { - "status": "success", - "account_status": "NO_COMPANY", - } + response = JSONResponse( + { + "status": "success", + "account_status": "NO_COMPANY", + } + ) + set_device_token_cookie(response, device_token) + return response diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index b2232530..18db0ad3 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -11,6 +11,7 @@ import aiomysql import psutil from config.config_db import get_db_pool +from config.config_system import EMAIL_REVERIFY_DAYS def _role(value: str | None) -> str: @@ -50,10 +51,11 @@ async def get_dashboard_me(user_id: int) -> dict[str, Any] | None: await cursor.execute( """SELECT u.id, u.email, u.name, u.position, u.department, u.phone, u.company_id, u.role, u.is_master, u.status, u.last_login, - u.auth_expires_at, c.name AS company_name + DATE_ADD(u.last_email_verified_at, INTERVAL %s DAY) AS auth_expires_at, + c.name AS company_name FROM users u LEFT JOIN companies c ON c.id = u.company_id WHERE u.id = %s AND u.deleted_at IS NULL""", - (user_id,), + (EMAIL_REVERIFY_DAYS, user_id), ) row = await cursor.fetchone() if row: @@ -327,8 +329,7 @@ async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: await cursor.execute( """UPDATE users SET company_id = %s, role = 'ADMIN', is_master = TRUE, - status = 'ACTIVE', - auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) + status = 'ACTIVE' WHERE id = %s""", (company_id, user_id), ) diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts new file mode 100644 index 00000000..d350dfdb --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -0,0 +1,64 @@ +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"; + +export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + L("B01_Dashboard_Table_Name"), + L("B01_Dashboard_Table_Position"), + L("B01_Dashboard_Table_Department"), + L("B01_Account_Field_Phone"), + L("B01_Dashboard_Table_Role"), + L("B01_Dashboard_Table_Status"), + L("B01_Dashboard_Table_Action"), + ], + users.map((user) => { + const actionsEl = document.createElement("div"); + actionsEl.className = "b01-dashboard__actions"; + + actionsEl.append( + createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditUserModal(currentUser, user), + }), + ); + + if (canChangeRole(currentUser, user)) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_ChangeRole"), + variant: "ghost", + onClick: () => openChangeRoleModal(user), + }), + ); + } + + return [ + text(user.email), + text(user.name), + text(user.position), + text(user.department), + text(user.phone), + text(user.role), + text(user.status), + actionsEl, + ]; + }), + ); +} + +export function auditLogTable(logs: AuditLog[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + L("B01_Dashboard_Table_Action"), + L("B01_Dashboard_Table_Updated"), + ], + logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts new file mode 100644 index 00000000..49f8114e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -0,0 +1,122 @@ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { + createButton, + createCard, + 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): Promise { + showLoadingOverlay(); + try { + await action(); + showToast(L("B01_Dashboard_Saved"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error"); + } finally { + hideLoadingOverlay(); + } +} + +export function formatDate(value?: string | null): string { + return value ? value.slice(0, 10) : "-"; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts new file mode 100644 index 00000000..e42a9338 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -0,0 +1,148 @@ +import { createButton, createTag } from "@ui/ui_template_elements"; +import { + processJoinRequest, + type CompanyInfo, + type DashboardUser, + type JoinRequest, + type Member, +} from "./B01_Dashboard_Api_Fetch"; +import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; +import { + openChangeRoleModal, + openCreateCompanyModal, + openDeleteUserModal, + openEditUserModal, + 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"; + +export function buildCompanyPanel(state: DashboardState): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-dashboard__actions"; + if (!state.company) { + wrap.append( + createTag(L("B01_Dashboard_NoCompany"), "warning"), + createButton({ + label: L("B01_Dashboard_CreateCompany"), + onClick: () => openCreateCompanyModal(), + }), + createButton({ + label: L("B01_Dashboard_FindCompany"), + variant: "ghost", + onClick: () => openFindCompanyModal(), + }), + ); + return wrap; + } + wrap.append( + createTag(`${state.company.name} (${state.user.status})`, "success"), + text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), + text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), + ); + return wrap; +} + +export function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + L("B01_Dashboard_Table_Name"), + L("B01_Dashboard_Table_Position"), + L("B01_Dashboard_Table_Department"), + L("B01_Dashboard_Table_Role"), + L("B01_Dashboard_Table_Action"), + ], + members.map((member) => { + const actionsEl = document.createElement("div"); + actionsEl.className = "b01-dashboard__actions"; + + actionsEl.append( + createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditUserModal(currentUser, member), + }), + ); + + if (canChangeRole(currentUser, member)) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_ChangeRole"), + variant: "ghost", + onClick: () => openChangeRoleModal(member), + }), + ); + } + + if (canDeleteUser(currentUser, member)) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_RemoveMember"), + variant: "danger", + onClick: () => openDeleteUserModal(member), + }), + ); + } + + return [ + text(member.email), + text(member.name), + text(member.position), + text(member.department), + text(member.role), + actionsEl, + ]; + }), + ); +} + +export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Email"), + ...(systemMode ? [L("B01_Dashboard_Table_Company")] : []), + L("B01_Dashboard_Table_Requested"), + L("B01_Dashboard_Table_Status"), + L("B01_Dashboard_Table_Action"), + ], + requests.map((request) => [ + text(request.user_email), + ...(systemMode ? [text(request.company_name)] : []), + text(formatDate(request.requested_at)), + text(request.status), + actionPair( + () => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode), + () => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode), + ), + ]), + ); +} + +export function companyTable(companies: CompanyInfo[]): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Company"), + L("B01_Dashboard_Field_BusinessNumber"), + L("B01_Dashboard_Table_Status"), + ], + companies.map((company) => [ + text(company.name), + text(company.business_registration_number), + text(company.business_status), + ]), + ); +} + +export async function onB01_JoinRequest_Process_Click( + requestId: number, + action: "APPROVE" | "REJECT", + systemMode: boolean, +): Promise { + await runRequest(() => + systemMode && action === "APPROVE" + ? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId)) + : processJoinRequest(requestId, action), + ); + window.dispatchEvent(new HashChangeEvent("hashchange")); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 844bb691..3ab0b486 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -1,19 +1,13 @@ -import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; -import { isBlank } from "@util/common_util_validate"; -import { navigateTo } from "../A00_Common/router"; -import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { ROUTES } from "@config/config_frontend"; import { createButton, - createCard, - createInputField, createTag, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { navigateTo } from "../A00_Common/router"; import { - changePassword, fetchAllCompanies, fetchAllJoinRequests, fetchAllProjects, @@ -26,8 +20,6 @@ import { fetchSystemResources, fetchUserCompany, fetchUserProjects, - processJoinRequest, - updateUserProfile, type AuditLog, type CompanyInfo, type DashboardUser, @@ -36,34 +28,21 @@ import { type ProjectItem, 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 { + buildCompanyPanel, + companyTable, + joinRequestTable, + memberTable, +} from "./B01_Dashboard_UI_Company"; +import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals"; +import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; +import { projectTable } from "./B01_Dashboard_UI_Projects"; import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; -import { - canEditProject, - canDeleteProject, - canChangeRole, - canDeleteUser, - canManageAutomation, -} from "./B01_Dashboard_UI_Helper"; -import { - openEditProjectModal, - openDeleteProjectModal, - openEditUserModal, - openChangeRoleModal, - openDeleteUserModal, - openAutomationModal, - openCreateCompanyModal, - openFindCompanyModal, - openAddMemberModal, -} from "./B01_Dashboard_UI_Modals"; import "./B01_Dashboard_UI_Style.css"; -const PASSWORD_MIN_LENGTH = 8; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -interface DashboardState { +export interface DashboardState { user: DashboardUser; userProjects: ProjectItem[]; companyProjects: ProjectItem[]; @@ -148,40 +127,6 @@ async function loadRoleData(state: DashboardState): Promise { } } -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; -} - -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; -} - function buildPage(state: DashboardState): HTMLElement { const page = document.createElement("div"); page.className = "b01-dashboard"; @@ -194,39 +139,24 @@ function buildPage(state: DashboardState): HTMLElement { grid.append( section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ - createButton({ - label: "+", - onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), - }), + createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ - createButton({ - label: "+", - onClick: () => openAddMemberModal(), - }), + createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true, [ - createButton({ - label: "+", - onClick: () => openCreateCompanyModal(), - }), + createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), ); } else if (state.user.role === "ADMIN") { grid.append( section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ - createButton({ - label: "+", - onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), - }), + createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ - createButton({ - label: "+", - onClick: () => openAddMemberModal(), - }), + createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true), section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), @@ -234,10 +164,7 @@ function buildPage(state: DashboardState): HTMLElement { } else { grid.append( section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ - createButton({ - label: "+", - onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER), - }), + createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), ); @@ -267,473 +194,3 @@ function buildHeader(user: DashboardUser): HTMLElement { header.append(text, tag); return header; } - -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"); -} - -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; -} - -function text(value: unknown): HTMLElement { - const span = document.createElement("span"); - span.textContent = value == null || value === "" ? "-" : String(value); - return span; -} - -function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Project"), - L("B01_Dashboard_Table_Region"), - L("B01_Dashboard_Table_Progress"), - L("B01_Dashboard_Table_Workflow"), - L("B01_Dashboard_Table_Updated"), - L("B01_Dashboard_Table_Action"), - ], - projects.map((project) => { - const actCell = document.createElement("div"); - actCell.className = "b01-dashboard__actions"; - - if (canEditProject(currentUser, project)) { - actCell.append( - createButton({ - label: L("Common_Btn_Edit"), - variant: "ghost", - onClick: () => openEditProjectModal(currentUser, project), - }), - ); - } - if (canDeleteProject(currentUser)) { - actCell.append( - createButton({ - label: L("Common_Btn_Delete"), - variant: "danger", - onClick: () => openDeleteProjectModal(project), - }), - ); - } - if (canManageAutomation(currentUser, project)) { - actCell.append( - createButton({ - label: L("B01_Dashboard_AutomationLogic"), - variant: "ghost", - onClick: () => openAutomationModal(project), - }), - ); - } - - return [ - text(project.name), - text(project.region), - text(`${project.progress_percent}%`), - workflow(project), - text(formatDate(project.updated_at)), - actCell, - ]; - }), - ); -} - -function workflow(project: ProjectItem): HTMLElement { - const routes: RoutePath[] = [ - ROUTES.B03_FILE_INPUT, - ROUTES.B04_WF1_SURFACE, - ROUTES.B05_WF2_ROUTE, - ROUTES.B06_WF3_PROFILE_CROSS, - ROUTES.B07_WF4_DESIGN_DETAIL, - ROUTES.B08_WF5_QUANTITY, - ROUTES.B09_WF6_ESTIMATION, - ]; - const box = document.createElement("div"); - box.className = "b01-dashboard__workflow"; - - const stages = project.workflow_state?.stages; - const stepLabels = workflowSteps(); - - routes.forEach((route, index) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b01-dashboard__step"; - button.textContent = stepLabels[index] ?? `B${String(index + 3).padStart(2, "0")}`; - - // 스텝바는 항상 자유롭게 이동 가능(게이팅하지 않음). - // 단계 완료/무효화 판정은 각 페이지의 액션(업로드·분석 실행 등) 시 백엔드가 - // 계산·DB 갱신하며, 여기서는 그 결과를 색상/툴팁으로 표시만 한다. - button.classList.add("is-enabled"); - button.addEventListener("click", () => { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, project.id); - navigateTo(route); - }); - - if (stages && stages[index]) { - const state = stages[index].state; - button.classList.add(`state-${state.toLowerCase()}`); - if (state === "STALE") { - button.title = "Stale (하위 단계 변경으로 무효화됨)"; - } else if (state === "FAILED") { - button.title = "Failed (실패)"; - } else if (state === "COMPLETE") { - button.title = "Complete (완료)"; - } else if (state === "IN_PROGRESS") { - button.title = "In Progress (진행 중)"; - } else { - button.title = "Not Started (미실행)"; - } - } - - box.append(button); - }); - return box; -} - -function buildCompanyPanel(state: DashboardState): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b01-dashboard__actions"; - if (!state.company) { - wrap.append( - createTag(L("B01_Dashboard_NoCompany"), "warning"), - createButton({ - label: L("B01_Dashboard_CreateCompany"), - onClick: () => openCreateCompanyModal(), - }), - createButton({ - label: L("B01_Dashboard_FindCompany"), - variant: "ghost", - onClick: () => openFindCompanyModal(), - }), - ); - return wrap; - } - wrap.append( - createTag(`${state.company.name} (${state.user.status})`, "success"), - text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), - text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), - ); - return wrap; -} - -function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Email"), - L("B01_Dashboard_Table_Name"), - L("B01_Dashboard_Table_Position"), - L("B01_Dashboard_Table_Department"), - L("B01_Dashboard_Table_Role"), - L("B01_Dashboard_Table_Action"), - ], - members.map((member) => { - const actionsEl = document.createElement("div"); - actionsEl.className = "b01-dashboard__actions"; - - actionsEl.append( - createButton({ - label: L("Common_Btn_Edit"), - variant: "ghost", - onClick: () => openEditUserModal(currentUser, member), - }), - ); - - if (canChangeRole(currentUser, member)) { - actionsEl.append( - createButton({ - label: L("B01_Dashboard_ChangeRole"), - variant: "ghost", - onClick: () => openChangeRoleModal(member), - }), - ); - } - - if (canDeleteUser(currentUser, member)) { - actionsEl.append( - createButton({ - label: L("B01_Dashboard_RemoveMember"), - variant: "danger", - onClick: () => openDeleteUserModal(member), - }), - ); - } - - return [ - text(member.email), - text(member.name), - text(member.position), - text(member.department), - text(member.role), - actionsEl, - ]; - }), - ); -} - -function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Email"), - ...(systemMode ? [L("B01_Dashboard_Table_Company")] : []), - L("B01_Dashboard_Table_Requested"), - L("B01_Dashboard_Table_Status"), - L("B01_Dashboard_Table_Action"), - ], - requests.map((request) => [ - text(request.user_email), - ...(systemMode ? [text(request.company_name)] : []), - text(formatDate(request.requested_at)), - text(request.status), - actionPair( - () => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode), - () => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode), - ), - ]), - ); -} - -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; -} - -function companyTable(companies: CompanyInfo[]): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Company"), - L("B01_Dashboard_Field_BusinessNumber"), - L("B01_Dashboard_Table_Status"), - ], - companies.map((company) => [ - text(company.name), - text(company.business_registration_number), - text(company.business_status), - ]), - ); -} - -function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Email"), - L("B01_Dashboard_Table_Name"), - L("B01_Dashboard_Table_Position"), - L("B01_Dashboard_Table_Department"), - L("B01_Account_Field_Phone"), - L("B01_Dashboard_Table_Role"), - L("B01_Dashboard_Table_Status"), - L("B01_Dashboard_Table_Action"), - ], - users.map((user) => { - const actionsEl = document.createElement("div"); - actionsEl.className = "b01-dashboard__actions"; - - actionsEl.append( - createButton({ - label: L("Common_Btn_Edit"), - variant: "ghost", - onClick: () => openEditUserModal(currentUser, user), - }), - ); - - if (canChangeRole(currentUser, user)) { - actionsEl.append( - createButton({ - label: L("B01_Dashboard_ChangeRole"), - variant: "ghost", - onClick: () => openChangeRoleModal(user), - }), - ); - } - - return [ - text(user.email), - text(user.name), - text(user.position), - text(user.department), - text(user.phone), - text(user.role), - text(user.status), - actionsEl, - ]; - }), - ); -} - -function auditLogTable(logs: AuditLog[]): HTMLElement { - return table( - [ - L("B01_Dashboard_Table_Email"), - L("B01_Dashboard_Table_Action"), - L("B01_Dashboard_Table_Updated"), - ], - logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), - ); -} - -function buildProfileForm(user: DashboardUser): HTMLElement { - const name = createInputField({ - label: L("B01_Account_Field_Name"), - value: user.name, - required: true, - }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: user.position ?? "", - }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: user.department ?? "", - }); - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); - const grid = document.createElement("div"); - grid.className = "b01-dashboard__form-grid"; - grid.append(name.root, position.root, department.root, phone.root); - const save = createButton({ - label: L("B01_Dashboard_SaveProfile"), - onClick: async function onB01_Profile_Save_Click() { - name.setError(); - if (isBlank(name.input.value)) { - name.setError(L("Common_Msg_RequiredField")); - return; - } - await runRequest(() => - updateUserProfile({ - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - }), - ); - }, - }); - const wrap = document.createElement("div"); - wrap.append(grid, save); - return wrap; -} - -function buildSecurityForm(): HTMLElement { - const currentPassword = createInputField({ - label: L("B01_Account_Field_CurrentPw"), - type: "password", - required: true, - }); - const newPassword = createInputField({ - label: L("B01_Account_Field_NewPw"), - type: "password", - required: true, - }); - const confirmPassword = createInputField({ - label: L("B01_Account_Field_ConfirmPw"), - type: "password", - required: true, - }); - const grid = document.createElement("div"); - grid.className = "b01-dashboard__form-grid"; - grid.append(currentPassword.root, newPassword.root, confirmPassword.root); - const save = createButton({ - label: L("B01_Account_Save_Password"), - variant: "ghost", - onClick: async function onB01_Password_Save_Click() { - currentPassword.setError(); - newPassword.setError(); - confirmPassword.setError(); - - const currentValue = currentPassword.input.value; - const nextValue = newPassword.input.value; - const confirmValue = confirmPassword.input.value; - if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) { - currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined); - newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined); - confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined); - return; - } - if (nextValue.length < PASSWORD_MIN_LENGTH) { - newPassword.setError(L("B01_Account_Error_PwLength")); - return; - } - if (nextValue !== confirmValue) { - confirmPassword.setError(L("B01_Account_Error_PwMismatch")); - return; - } - await runRequest(() => - changePassword({ - current_password: currentValue, - new_password: nextValue, - new_password_confirm: confirmValue, - logout_all: false, - }), - ); - currentPassword.input.value = ""; - newPassword.input.value = ""; - confirmPassword.input.value = ""; - }, - }); - const wrap = document.createElement("div"); - wrap.append(grid, save); - return wrap; -} - -async function onB01_JoinRequest_Process_Click( - requestId: number, - action: "APPROVE" | "REJECT", - systemMode: boolean, -): Promise { - await runRequest(() => - systemMode && action === "APPROVE" - ? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId)) - : processJoinRequest(requestId, action), - ); - window.dispatchEvent(new HashChangeEvent("hashchange")); -} - -async function runRequest(action: () => Promise): Promise { - showLoadingOverlay(); - try { - await action(); - showToast(L("B01_Dashboard_Saved"), "success"); - } catch (error) { - showToast(error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"), "error"); - } finally { - hideLoadingOverlay(); - } -} - -function formatDate(value?: string | null): string { - return value ? value.slice(0, 10) : "-"; -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts new file mode 100644 index 00000000..ef110687 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -0,0 +1,109 @@ +import { isBlank } from "@util/common_util_validate"; +import { createButton, createInputField } from "@ui/ui_template_elements"; +import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { L, runRequest } from "./B01_Dashboard_UI_Common"; + +const PASSWORD_MIN_LENGTH = 8; + +export function buildProfileForm(user: DashboardUser): HTMLElement { + const name = createInputField({ + label: L("B01_Account_Field_Name"), + value: user.name, + required: true, + }); + const position = createInputField({ + label: L("B01_Dashboard_Table_Position"), + value: user.position ?? "", + }); + const department = createInputField({ + label: L("B01_Dashboard_Table_Department"), + value: user.department ?? "", + }); + const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__form-grid"; + grid.append(name.root, position.root, department.root, phone.root); + const save = createButton({ + label: L("B01_Dashboard_SaveProfile"), + onClick: async function onB01_Profile_Save_Click() { + name.setError(); + if (isBlank(name.input.value)) { + name.setError(L("Common_Msg_RequiredField")); + return; + } + await runRequest(() => + updateUserProfile({ + name: name.input.value.trim(), + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + phone: phone.input.value.trim() || null, + }), + ); + }, + }); + const wrap = document.createElement("div"); + wrap.append(grid, save); + return wrap; +} + +export function buildSecurityForm(): HTMLElement { + const currentPassword = createInputField({ + label: L("B01_Account_Field_CurrentPw"), + type: "password", + required: true, + }); + const newPassword = createInputField({ + label: L("B01_Account_Field_NewPw"), + type: "password", + required: true, + }); + const confirmPassword = createInputField({ + label: L("B01_Account_Field_ConfirmPw"), + type: "password", + required: true, + }); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__form-grid"; + grid.append(currentPassword.root, newPassword.root, confirmPassword.root); + const save = createButton({ + label: L("B01_Account_Save_Password"), + variant: "ghost", + onClick: async function onB01_Password_Save_Click() { + currentPassword.setError(); + newPassword.setError(); + confirmPassword.setError(); + + const currentValue = currentPassword.input.value; + const nextValue = newPassword.input.value; + const confirmValue = confirmPassword.input.value; + if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) { + currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined); + newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined); + confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined); + return; + } + if (nextValue.length < PASSWORD_MIN_LENGTH) { + newPassword.setError(L("B01_Account_Error_PwLength")); + return; + } + if (nextValue !== confirmValue) { + confirmPassword.setError(L("B01_Account_Error_PwMismatch")); + return; + } + await runRequest(() => + changePassword({ + current_password: currentValue, + new_password: nextValue, + new_password_confirm: confirmValue, + logout_all: false, + }), + ); + currentPassword.input.value = ""; + newPassword.input.value = ""; + confirmPassword.input.value = ""; + }, + }); + const wrap = document.createElement("div"); + wrap.append(grid, save); + return wrap; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts new file mode 100644 index 00000000..005c61bd --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -0,0 +1,80 @@ +import { createButton } from "@ui/ui_template_elements"; +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"; + +export function projectTable(projects: ProjectItem[], currentUser: DashboardUser): HTMLElement { + return table( + [ + L("B01_Dashboard_Table_Project"), + L("B01_Dashboard_Table_Region"), + L("B01_Dashboard_Table_Progress"), + L("B01_Dashboard_Table_Workflow"), + L("B01_Dashboard_Table_Updated"), + L("B01_Dashboard_Table_Action"), + ], + projects.map((project) => { + const actCell = document.createElement("div"); + actCell.className = "b01-dashboard__actions"; + + if (canEditProject(currentUser, project)) { + actCell.append( + createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditProjectModal(currentUser, project), + }), + ); + } + if (canDeleteProject(currentUser)) { + actCell.append( + createButton({ + label: L("Common_Btn_Delete"), + variant: "danger", + onClick: () => openDeleteProjectModal(project), + }), + ); + } + if (canManageAutomation(currentUser, project)) { + actCell.append( + createButton({ + label: L("B01_Dashboard_AutomationLogic"), + variant: "ghost", + onClick: () => openAutomationModal(project), + }), + ); + } + + return [ + text(project.name), + text(project.region), + text(`${project.progress_percent}%`), + workflow(project), + text(formatDate(project.updated_at)), + actCell, + ]; + }), + ); +} + +export function workflow(project: ProjectItem): HTMLElement { + return createStepBar( + workflowSteps(), + project.workflow_state?.current_stage ?? project.workflow_stage, + { + stages: project.workflow_state?.stages, + currentStage: project.workflow_state?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + compact: true, + onStepClick: (stepIndex) => goToWorkflowStage(project.id, WORKFLOW_STEP_ROUTES[stepIndex]), + }, + ); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index e18d4cfc..00877848 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -81,30 +81,6 @@ font-size: var(--text-body-sm); } -.b01-dashboard__workflow { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4); -} - -.b01-dashboard__step { - height: 28px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); - background: var(--color-surface); - color: var(--color-text-muted); - font-size: var(--text-caption); - white-space: nowrap; - cursor: default; -} - -.b01-dashboard__step.is-enabled { - background: var(--color-mist-violet); - color: var(--color-accent); - cursor: pointer; -} - .b01-dashboard__metric-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index b9171659..9dc706f5 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -1,13 +1,15 @@ import { CURRENT_PROJECT_ID_KEY, PROGRESS_UPDATE_INTERVAL_MS, + ROUTES, UPLOAD_ALLOWED_EXT, UPLOAD_CHUNK_SIZE_MB, UPLOAD_MAX_FILES, UPLOAD_MAX_MB, } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createButton, createTag, createWorkflowShell, showToast } from "@ui/ui_template_elements"; +import { createButton, createTag, showToast } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { checkWF1AnalysisStatus, createUploadSession, @@ -16,7 +18,12 @@ import { type UploadedFileResult, } from "./B03_FileInput_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; -import { ROUTES } from "@config/config_frontend"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, +} from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState, saveB03UploadedFile, @@ -167,7 +174,7 @@ function createFileCardTemplate(): HTMLTemplateElement { return template; } -export function renderB03FileInput(root: HTMLElement): void { +export async function renderB03FileInput(root: HTMLElement): Promise { const slots = initializeSlots(); const cardMap = new Map(); const resultList = document.createElement("ul"); @@ -176,26 +183,6 @@ export function renderB03FileInput(root: HTMLElement): void { let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - const shell = createWorkflowShell({ - title: L("B03_File_Title"), - steps: [ - L("B03_File_Title"), - L("WF_Step_Surface"), - L("WF_Step_Route"), - L("WF_Step_ProfileCross"), - L("WF_Step_DesignDetail"), - L("WF_Step_Quantity"), - L("WF_Step_Estimation"), - ], - activeStep: 0, - }); - shell.root.classList.add("b03-file"); - - shell.leftPanel.remove(); - - const contentContainer = document.createElement("div"); - contentContainer.className = "b03-file__main-layout"; - const subtitle = document.createElement("p"); subtitle.className = "b03-file__subtitle"; subtitle.textContent = L("B03_File_Subtitle"); @@ -673,13 +660,30 @@ export function renderB03FileInput(root: HTMLElement): void { const filesGroup = createCardGroup("", ["las_laz", "prj", "tfw", "tif"]); // 타이틀 공백으로 전달 const cardsContainer = document.createElement("div"); - cardsContainer.className = "b03-file__control-panel b03-file__cards-container-panel"; // 동일한 양식으로 감싸기 + cardsContainer.className = + "b03-file__main-layout b03-file__control-panel b03-file__cards-container-panel"; // 동일한 양식으로 감싸기 cardsContainer.append(filesGroup); - contentContainer.append(uploadControlPanel, cardsContainer); - - shell.rightArea.replaceChildren(contentContainer); - root.replaceChildren(shell.root); + 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, + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (activeProjectId) { + goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); + } + }, + }); + layout.root.classList.add("b03-file"); + root.replaceChildren(layout.root); for (const slot of slots.keys()) renderSlot(slot); void registerB03ServiceWorker(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 9959e75f..76dd4617 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -11,9 +11,8 @@ * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { navigateTo } from "../A00_Common/router"; import { createButton, createTag, @@ -22,6 +21,12 @@ import { showToast, } from "@ui/ui_template_elements"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, + type WorkflowState, +} from "../A00_Common/b_workflow_nav"; import { analyzeSurface, fetchSurfaceGroundStats, @@ -33,7 +38,7 @@ import { type SurfaceModelSummary, type SurfaceStatusResponse, } from "./B04_wf1_Surface_Api_Fetch"; -import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer"; import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer"; import "./B04_wf1_Surface_UI_Style.css"; @@ -191,27 +196,13 @@ export async function renderB04Surface(root: HTMLElement): Promise { bottom.append(statsSection, modelsSection); workspace.append(topbar, viewer.root, terrainViewer.root, bottom); - const workflowRoutes = [ - ROUTES.B03_FILE_INPUT, - ROUTES.B04_WF1_SURFACE, - ROUTES.B05_WF2_ROUTE, - ROUTES.B06_WF3_PROFILE_CROSS, - ROUTES.B07_WF4_DESIGN_DETAIL, - ROUTES.B08_WF5_QUANTITY, - ROUTES.B09_WF6_ESTIMATION, - ]; - - let workflowStages: WorkflowStage[] = []; + let workflowState: WorkflowState | undefined; const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (layoutProjectId) { try { - const res = await fetch(`/api/projects/${layoutProjectId}/workflow-state`); - if (res.ok) { - const data = await res.json(); - workflowStages = data.workflow_state?.stages ?? data.stages ?? []; - } + workflowState = await fetchWorkflowState(layoutProjectId); } catch { - /* 실패 시 빈 배열 → activeStep 기준 폴백으로 이동 허용 */ + /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ } } @@ -221,12 +212,12 @@ export async function renderB04Surface(root: HTMLElement): Promise { activeStep: 1, leftPanel: panel, mainContent: workspace, - stages: workflowStages, - routes: workflowRoutes, - onStepClick: (_stepIndex, route) => { + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { if (layoutProjectId) { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, layoutProjectId); - navigateTo(route as RoutePath); + goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]); } }, }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index e791845d..25420441 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -3,7 +3,7 @@ * 로그인 후 05: 2차 워크플로우 (경로 설계) * * 3단 레이아웃 (frontend.md §2): - * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell) + * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout) * 좌측: 경로 제어점(BP/EP/CP) 좌표 + 기반 지표면 + 설계 제약 폼 * 우측: 경로 탐색 결과(연장·경사·비용) 카드 * @@ -11,7 +11,7 @@ * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, @@ -22,9 +22,14 @@ import { showToast, type InputFieldHandle, } from "@ui/ui_template_elements"; -import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { navigateTo } from "../A00_Common/router"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, + type WorkflowState, +} from "../A00_Common/b_workflow_nav"; import { confirmRoute, solveRoute, @@ -320,27 +325,13 @@ export async function renderB05Route(root: HTMLElement): Promise { renderEmptyResult(); - const workflowRoutes = [ - ROUTES.B03_FILE_INPUT, - ROUTES.B04_WF1_SURFACE, - ROUTES.B05_WF2_ROUTE, - ROUTES.B06_WF3_PROFILE_CROSS, - ROUTES.B07_WF4_DESIGN_DETAIL, - ROUTES.B08_WF5_QUANTITY, - ROUTES.B09_WF6_ESTIMATION, - ]; - - let workflowStages: WorkflowStage[] = []; + let workflowState: WorkflowState | undefined; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (projectId) { try { - const res = await fetch(`/api/projects/${projectId}/workflow-state`); - if (res.ok) { - const data = await res.json(); - workflowStages = data.workflow_state?.stages ?? data.stages ?? []; - } + workflowState = await fetchWorkflowState(projectId); } catch { - /* 실패 시 빈 배열 → activeStep 기준 폴백으로 이동 허용 */ + /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ } } @@ -350,12 +341,12 @@ export async function renderB05Route(root: HTMLElement): Promise { activeStep: 2, leftPanel: leftForm, mainContent: resultCard, - stages: workflowStages, - routes: workflowRoutes, - onStepClick: (_stepIndex, route) => { + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { if (projectId) { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); - navigateTo(route as RoutePath); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); } }, }); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index e6c81db1..e4a54374 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -3,7 +3,7 @@ * 로그인 후 06: 3차 워크플로우 (종·횡단 생성) * * 3단 레이아웃 (frontend.md §2): - * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowShell) + * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout) * 좌측: 대상 경로 ID + 지표면 참조 + 측점/횡단 옵션 폼 * 우측: 종·횡단 생성 결과(연장·횡단 개수·파일) 카드 * @@ -11,7 +11,7 @@ * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). * ========================================================================== */ -import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, @@ -20,9 +20,14 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; -import { navigateTo } from "../A00_Common/router"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, + type WorkflowState, +} from "../A00_Common/b_workflow_nav"; import { confirmSections, generateSections, @@ -242,27 +247,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { renderEmptyResult(); - const workflowRoutes = [ - ROUTES.B03_FILE_INPUT, - ROUTES.B04_WF1_SURFACE, - ROUTES.B05_WF2_ROUTE, - ROUTES.B06_WF3_PROFILE_CROSS, - ROUTES.B07_WF4_DESIGN_DETAIL, - ROUTES.B08_WF5_QUANTITY, - ROUTES.B09_WF6_ESTIMATION, - ]; - - let workflowStages: WorkflowStage[] = []; + let workflowState: WorkflowState | undefined; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (projectId) { try { - const res = await fetch(`/api/projects/${projectId}/workflow-state`); - if (res.ok) { - const data = await res.json(); - workflowStages = data.workflow_state?.stages ?? data.stages ?? []; - } + workflowState = await fetchWorkflowState(projectId); } catch { - /* 실패 시 빈 배열 → activeStep 기준 폴백으로 이동 허용 */ + /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ } } @@ -272,12 +263,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { activeStep: 3, leftPanel: leftForm, mainContent: resultCard, - stages: workflowStages, - routes: workflowRoutes, - onStepClick: (_stepIndex, route) => { + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { if (projectId) { - localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); - navigateTo(route as RoutePath); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); } }, }); diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts index 6915ac02..1be7410e 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts @@ -3,7 +3,7 @@ * 로그인 후 07: 4차 워크플로우 (상세 설계) * * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용. + * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; @@ -17,8 +17,8 @@ function L(key: keyof typeof ui_locales): string { /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ -export function renderB07DesignDetail(root: HTMLElement): void { - renderPendingWorkflow(root, { +export async function renderB07DesignDetail(root: HTMLElement): Promise { + await renderPendingWorkflow(root, { title: L("B07_Design_Title"), steps: workflowSteps(), activeStep: 4, diff --git a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts index 5128a6cf..869947ba 100644 --- a/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts +++ b/B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts @@ -3,7 +3,7 @@ * 로그인 후 08: 5차 워크플로우 (수량 산출) * * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용. + * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; @@ -17,8 +17,8 @@ function L(key: keyof typeof ui_locales): string { /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ -export function renderB08Quantity(root: HTMLElement): void { - renderPendingWorkflow(root, { +export async function renderB08Quantity(root: HTMLElement): Promise { + await renderPendingWorkflow(root, { title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, diff --git a/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts b/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts index 5e530f7f..56d28884 100644 --- a/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts +++ b/B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts @@ -3,7 +3,7 @@ * 로그인 후 09: 6차 워크플로우 (견적·문서) * * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowShell 재사용. + * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; @@ -17,8 +17,8 @@ function L(key: keyof typeof ui_locales): string { /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ -export function renderB09Estimation(root: HTMLElement): void { - renderPendingWorkflow(root, { +export async function renderB09Estimation(root: HTMLElement): Promise { + await renderPendingWorkflow(root, { title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index ab123962..11e26c51 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -12,6 +12,8 @@ from fastapi.responses import Response from config.config_db import get_db_pool from config.config_system import ( + DEVICE_TOKEN_COOKIE_NAME, + EMAIL_REVERIFY_DAYS, PASSWORD_BCRYPT_ROUNDS, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, @@ -39,6 +41,30 @@ def hash_user_agent(user_agent: str) -> str: return hashlib.sha256(normalized.encode("utf-8")).hexdigest() +def generate_device_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_device_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def get_device_token_cookie(request: Request) -> str | None: + return request.cookies.get(DEVICE_TOKEN_COOKIE_NAME) + + +def set_device_token_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=DEVICE_TOKEN_COOKIE_NAME, + value=token, + max_age=EMAIL_REVERIFY_DAYS * 24 * 60 * 60, + secure=SESSION_COOKIE_SECURE, + httponly=True, + samesite="lax", + path="/", + ) + + def set_session_cookie(response: Response, session_id: str) -> None: response.set_cookie( key=SESSION_COOKIE_NAME, diff --git a/common_util/common_util_auth_repository.py b/common_util/common_util_auth_repository.py index 95c0925f..ced86cdd 100644 --- a/common_util/common_util_auth_repository.py +++ b/common_util/common_util_auth_repository.py @@ -9,7 +9,7 @@ from typing import Any import aiomysql from config.config_db import get_db_pool -from config.config_system import OTP_VALID_MINUTES +from config.config_system import EMAIL_REVERIFY_DAYS, OTP_VALID_MINUTES def _company_code() -> str: @@ -152,8 +152,7 @@ async def complete_registration(user_id: int, is_master: bool) -> None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( - """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP, - auth_expires_at = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) + """UPDATE users SET status = 'NO_COMPANY', last_email_verified_at = CURRENT_TIMESTAMP WHERE id = %s""", (user_id,), ) @@ -226,23 +225,49 @@ async def clear_login_failures(user_id: int) -> None: await connection.commit() -async def has_known_browser(user_id: int, user_agent_hash: str) -> bool: +async def has_trusted_device(user_id: int, token_hash: str) -> bool: + valid_after = datetime.utcnow() - timedelta(days=EMAIL_REVERIFY_DAYS) pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( - "SELECT 1 FROM trusted_devices WHERE user_id = %s AND user_agent_hash = %s LIMIT 1", - (user_id, user_agent_hash), + """SELECT id FROM trusted_devices + WHERE user_id = %s AND token_hash = %s AND last_used_at >= %s LIMIT 1""", + (user_id, token_hash, valid_after), ) - return await cursor.fetchone() is not None + row = await cursor.fetchone() + if not row: + return False + await cursor.execute( + "UPDATE trusted_devices SET last_used_at = CURRENT_TIMESTAMP WHERE id = %s", + (row[0],), + ) + await connection.commit() + return True -async def trust_browser(user_id: int, user_agent_hash: str) -> None: +async def trust_device( + user_id: int, + token_hash: str, + user_agent_hash: str, + previous_token_hash: str | None = None, +) -> None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: + if previous_token_hash: + await cursor.execute( + """UPDATE trusted_devices + SET token_hash = %s, user_agent_hash = %s, + verified_at = CURRENT_TIMESTAMP, last_used_at = CURRENT_TIMESTAMP + WHERE user_id = %s AND token_hash = %s""", + (token_hash, user_agent_hash, user_id, previous_token_hash), + ) + if cursor.rowcount: + await connection.commit() + return await cursor.execute( - """INSERT INTO trusted_devices (user_id, user_agent_hash) - VALUES (%s, %s) ON DUPLICATE KEY UPDATE last_used_at = CURRENT_TIMESTAMP""", - (user_id, user_agent_hash), + """INSERT INTO trusted_devices (user_id, user_agent_hash, token_hash) + VALUES (%s, %s, %s)""", + (user_id, user_agent_hash, token_hash), ) await connection.commit() diff --git a/config/config_system.py b/config/config_system.py index 75dde6c3..b26a2139 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -284,6 +284,7 @@ def get_project_storage_path(company: str, user: str, project_id: str) -> str: # 7. 인증 및 이메일 # ───────────────────────────────────────────────────────────────────────── SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "session_id") +DEVICE_TOKEN_COOKIE_NAME = os.getenv("DEVICE_TOKEN_COOKIE_NAME", "device_token") SESSION_MAX_AGE_SECONDS = int(os.getenv("SESSION_MAX_AGE_SECONDS", "43200")) SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv("SESSION_IDLE_TIMEOUT_SECONDS", "14400")) SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true" diff --git a/db_management/007_trusted_device_token.sql b/db_management/007_trusted_device_token.sql new file mode 100644 index 00000000..466e70d5 --- /dev/null +++ b/db_management/007_trusted_device_token.sql @@ -0,0 +1,17 @@ +-- HttpOnly 디바이스 토큰 기반 신뢰 기기 전환 (MariaDB 10.6+) +USE aislo_db; + +ALTER TABLE trusted_devices + ADD COLUMN IF NOT EXISTS token_hash VARCHAR(64) NULL AFTER user_agent_hash; + +-- 동일한 User-Agent를 쓰는 여러 브라우저를 각각의 토큰으로 구분한다. +-- user_id 외래키가 기존 복합 UNIQUE 인덱스에 의존하므로 일반 인덱스를 먼저 보장한다. +CREATE INDEX IF NOT EXISTS idx_trusted_devices_user_id + ON trusted_devices (user_id); +DROP INDEX IF EXISTS uq_trusted_devices_user_agent ON trusted_devices; +CREATE UNIQUE INDEX IF NOT EXISTS uq_trusted_devices_token_hash + ON trusted_devices (token_hash); + +-- 재인증 만료는 last_email_verified_at + EMAIL_REVERIFY_DAYS로 계산한다. +ALTER TABLE users + DROP COLUMN IF EXISTS auth_expires_at; diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json new file mode 100644 index 00000000..a919fdef --- /dev/null +++ b/graphify-out/manifest.json @@ -0,0 +1,352 @@ +{ + "docs/wiki/AGENTS.md": { + "mtime": 1783834983.0, + "ast_hash": "74d04caee1f70d588da20af0f2224049", + "semantic_hash": "" + }, + "docs/wiki/CLAUDE.md": { + "mtime": 1783834983.0, + "ast_hash": "74d04caee1f70d588da20af0f2224049", + "semantic_hash": "" + }, + "docs/wiki/concepts/a00_app_shell_framework.md": { + "mtime": 1784259049.8622963, + "ast_hash": "56c3c5418a6a54f89afe3732cadaaaec", + "semantic_hash": "" + }, + "docs/wiki/concepts/a00_app_shell_framework_scaffold.md": { + "mtime": 1783844389.0, + "ast_hash": "b08d53673fa62ab2ae17105341f6beda", + "semantic_hash": "" + }, + "docs/wiki/concepts/api_common.md": { + "mtime": 1783849575.0, + "ast_hash": "aa36d7b826c4ac0349cd64a64cde5fe4", + "semantic_hash": "" + }, + "docs/wiki/concepts/auth_rbac.md": { + "mtime": 1784259833.4644027, + "ast_hash": "2c6ad199f866257eca3bec738fcf5f3d", + "semantic_hash": "" + }, + "docs/wiki/concepts/common_util.md": { + "mtime": 1784259848.5701928, + "ast_hash": "941f7272498ffc25ba1deeb94c9c37b3", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/files_surface.md": { + "mtime": 1784259351.9277234, + "ast_hash": "7eb0461ec86ee4eae0ff72406e41f1cd", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/logs_monitoring.md": { + "mtime": 1784259355.6293466, + "ast_hash": "d394ba4d2cb09a6651f574352750eba0", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/overview.md": { + "mtime": 1784259347.9511518, + "ast_hash": "8f35d8e4f659eee10ef9e01411cdc079", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/projects.md": { + "mtime": 1784259359.3317523, + "ast_hash": "f037e166e7b72002dfa3c7552a0eae43", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/route_profile.md": { + "mtime": 1784259366.082059, + "ast_hash": "9ba725f36208f26e1c8d0d9f6179325c", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/structure_output.md": { + "mtime": 1784259369.8867612, + "ast_hash": "92adeedeb94bb3b75b0ba4a42c75ea15", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/unconfirmed/README.md": { + "mtime": 1784259342.919075, + "ast_hash": "14b964e21ad8705d3fb2c6c818f71750", + "semantic_hash": "" + }, + "docs/wiki/concepts/db_schema/users_auth.md": { + "mtime": 1784259859.5247, + "ast_hash": "4252d7048a05615e6420fb2853567323", + "semantic_hash": "" + }, + "docs/wiki/concepts/dependencies.md": { + "mtime": 1783850549.0, + "ast_hash": "93732554454116e3543ac2269b9d1f0d", + "semantic_hash": "" + }, + "docs/wiki/concepts/schema_common.md": { + "mtime": 1783844389.0, + "ast_hash": "ebde162a912c33c17bd42e3edb469bc6", + "semantic_hash": "" + }, + "docs/wiki/concepts/storage_paths.md": { + "mtime": 1783850555.0, + "ast_hash": "2cf2c535406b15d5f2d7d5cde19fd4d2", + "semantic_hash": "" + }, + "docs/wiki/concepts/ui_templates.md": { + "mtime": 1784260037.317444, + "ast_hash": "26b52705999f61d424a9060fd8509dcc", + "semantic_hash": "" + }, + "docs/wiki/concepts/workflow_state.md": { + "mtime": 1784259062.3032951, + "ast_hash": "b5be92081d3d51e7aba4181ff3f7b677", + "semantic_hash": "" + }, + "docs/wiki/index.md": { + "mtime": 1784260033.8125088, + "ast_hash": "9a8f4621e4acf485cd1d9c4909e077de", + "semantic_hash": "" + }, + "docs/wiki/log.md": { + "mtime": 1784259880.8909566, + "ast_hash": "157d6ebdee9f0c5ccd727e6ebd506260", + "semantic_hash": "" + }, + "docs/wiki/pages/A01_Home/A01_components.md": { + "mtime": 1783849656.0, + "ast_hash": "af5724435a2da0845f42aed322971f7a", + "semantic_hash": "" + }, + "docs/wiki/pages/A01_Home/A01_frontend.md": { + "mtime": 1783849659.0, + "ast_hash": "59d5bcdf4d88a0e576c55c5752579a23", + "semantic_hash": "" + }, + "docs/wiki/pages/A02_ProgDetail/A02_components.md": { + "mtime": 1783849775.0, + "ast_hash": "d663c36e0202f099a2602d631c2f962d", + "semantic_hash": "" + }, + "docs/wiki/pages/A02_ProgDetail/A02_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "b590aa6c5a13b2478592e23803f5398d", + "semantic_hash": "" + }, + "docs/wiki/pages/A03_CompDetail/A03_frontend.md": { + "mtime": 1783844088.0, + "ast_hash": "833dd9355fb96b46b0f94271a642c216", + "semantic_hash": "" + }, + "docs/wiki/pages/A04_NewsHistory/A04_frontend.md": { + "mtime": 1783844098.0, + "ast_hash": "5bb83dca8f9f11f0122f5911b295e279", + "semantic_hash": "" + }, + "docs/wiki/pages/A05_EduDetail/A05_frontend.md": { + "mtime": 1783844108.0, + "ast_hash": "fadf785b9ebad689862cb8cfa9827fbd", + "semantic_hash": "" + }, + "docs/wiki/pages/A06_Login/A06_backend.md": { + "mtime": 1784259816.2009697, + "ast_hash": "8ff447e9f595b09ab8706e6b2153c2e4", + "semantic_hash": "" + }, + "docs/wiki/pages/A06_Login/A06_frontend.md": { + "mtime": 1784259825.8575397, + "ast_hash": "b77699e691f04c97e2676b56a978d441", + "semantic_hash": "" + }, + "docs/wiki/pages/A07_Register/A07_backend.md": { + "mtime": 1784259843.9720101, + "ast_hash": "b2ba632dc7648b9925828c57fb94073f", + "semantic_hash": "" + }, + "docs/wiki/pages/A07_Register/A07_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "0c30b980601414cefa02c05ba50a9008", + "semantic_hash": "" + }, + "docs/wiki/pages/A08_Support/A08_backend.md": { + "mtime": 1783849900.0, + "ast_hash": "d2dd1028a837d8c17429179c4ffd7135", + "semantic_hash": "" + }, + "docs/wiki/pages/A08_Support/A08_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "f2ebae60bb02320db4ac39e0a66b9d69", + "semantic_hash": "" + }, + "docs/wiki/pages/A09_Security/A09_backend.md": { + "mtime": 1783849775.0, + "ast_hash": "f96ba36247f269672317bc352c705a7c", + "semantic_hash": "" + }, + "docs/wiki/pages/A09_Security/A09_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "d90b0617abec1376bd32e140b0eb1f63", + "semantic_hash": "" + }, + "docs/wiki/pages/B01_Dashboard/B01_api.md": { + "mtime": 1784259230.2413194, + "ast_hash": "cec23a7fa764aa9cb89ca556386a9d1e", + "semantic_hash": "" + }, + "docs/wiki/pages/B01_Dashboard/B01_backend.md": { + "mtime": 1784259865.0189633, + "ast_hash": "d3a676bc366985ef5ec949ae15a3d3cf", + "semantic_hash": "" + }, + "docs/wiki/pages/B01_Dashboard/B01_db.md": { + "mtime": 1783850631.0, + "ast_hash": "e7467e7ab25de8a576244c8595fefca4", + "semantic_hash": "" + }, + "docs/wiki/pages/B01_Dashboard/B01_dependencies.md": { + "mtime": 1784259237.7637663, + "ast_hash": "b772aa70b5f90deb37c5324f93d79a80", + "semantic_hash": "" + }, + "docs/wiki/pages/B01_Dashboard/B01_frontend.md": { + "mtime": 1784259258.671021, + "ast_hash": "817c78981729333cd1542b944f7758de", + "semantic_hash": "" + }, + "docs/wiki/pages/B02_ProjRegister/B02_backend.md": { + "mtime": 1783859694.0, + "ast_hash": "ec7d0edddc708060b31ca0058630cd1c", + "semantic_hash": "" + }, + "docs/wiki/pages/B02_ProjRegister/B02_db.md": { + "mtime": 1783849775.0, + "ast_hash": "ca17bc76462201e2ce50e754fbe7c816", + "semantic_hash": "" + }, + "docs/wiki/pages/B02_ProjRegister/B02_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "407638de4f1ed56a3ba72c5c244d8ebd", + "semantic_hash": "" + }, + "docs/wiki/pages/B03_FileInput/B03_api.md": { + "mtime": 1784259243.9075582, + "ast_hash": "96a8eb8ea278e761ddf0a3a0e2693728", + "semantic_hash": "" + }, + "docs/wiki/pages/B03_FileInput/B03_backend.md": { + "mtime": 1784259247.156907, + "ast_hash": "2955910cfa45dad1e46c7a4141c779d6", + "semantic_hash": "" + }, + "docs/wiki/pages/B03_FileInput/B03_db.md": { + "mtime": 1783850637.0, + "ast_hash": "229bc7bb89bc7fb3a745fa7a1eb56640", + "semantic_hash": "" + }, + "docs/wiki/pages/B03_FileInput/B03_dependencies.md": { + "mtime": 1784259157.4293892, + "ast_hash": "8ba6d765120bd79067f87dad58605dba", + "semantic_hash": "" + }, + "docs/wiki/pages/B03_FileInput/B03_frontend.md": { + "mtime": 1784259161.700674, + "ast_hash": "9d93af1df4e3350bb8775731e32ecc50", + "semantic_hash": "" + }, + "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { + "mtime": 1783849775.0, + "ast_hash": "98dc9571c054a9cc67f41e34c924afc4", + "semantic_hash": "" + }, + "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { + "mtime": 1783849775.0, + "ast_hash": "0c5544f6648212b90660aa84f35982ba", + "semantic_hash": "" + }, + "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { + "mtime": 1783849775.0, + "ast_hash": "7cb52c0844a907b14cfbd5230988c56f", + "semantic_hash": "" + }, + "docs/wiki/pages/B04_wf1_Surface/B04_dependencies.md": { + "mtime": 1783849775.0, + "ast_hash": "15bd0faa0411264c14b0bbd89f7afd46", + "semantic_hash": "" + }, + "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "5735fc2ab57c3965dcf50149b078fc93", + "semantic_hash": "" + }, + "docs/wiki/pages/B05_wf2_Route/B05_api.md": { + "mtime": 1784259165.6230073, + "ast_hash": "1eeb9b6e80f8a2122b61714669b85e1b", + "semantic_hash": "" + }, + "docs/wiki/pages/B05_wf2_Route/B05_backend.md": { + "mtime": 1784259169.115432, + "ast_hash": "50c0d1a2d67fbe66e3885cabae81d6cf", + "semantic_hash": "" + }, + "docs/wiki/pages/B05_wf2_Route/B05_db.md": { + "mtime": 1783850658.0, + "ast_hash": "c45788b5db47a7a4f246b0714a2c11e3", + "semantic_hash": "" + }, + "docs/wiki/pages/B05_wf2_Route/B05_dependencies.md": { + "mtime": 1784259171.929299, + "ast_hash": "96757fcd55e66155d680aa611106b5ce", + "semantic_hash": "" + }, + "docs/wiki/pages/B05_wf2_Route/B05_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "3a5bba842808d5a7a935882514495311", + "semantic_hash": "" + }, + "docs/wiki/pages/B06_wf3_ProfileCross/B06_api.md": { + "mtime": 1784259175.4055898, + "ast_hash": "a5989dca5de549ea7084f4291b554275", + "semantic_hash": "" + }, + "docs/wiki/pages/B06_wf3_ProfileCross/B06_backend.md": { + "mtime": 1784259178.6757984, + "ast_hash": "5a0495ebd2ba0f22c5e87703572ad1ae", + "semantic_hash": "" + }, + "docs/wiki/pages/B06_wf3_ProfileCross/B06_db.md": { + "mtime": 1783850703.0, + "ast_hash": "63b1c8f0287b4b277633da8c4617b03f", + "semantic_hash": "" + }, + "docs/wiki/pages/B06_wf3_ProfileCross/B06_dependencies.md": { + "mtime": 1784259182.2659357, + "ast_hash": "1033c52fc6823211db0690aafa706a83", + "semantic_hash": "" + }, + "docs/wiki/pages/B06_wf3_ProfileCross/B06_frontend.md": { + "mtime": 1783849775.0, + "ast_hash": "98cdce5d523d5750350c1c8eeb41ae8e", + "semantic_hash": "" + }, + "docs/wiki/pages/B07_wf4_DesignDetail/B07_frontend.md": { + "mtime": 1783844389.0, + "ast_hash": "d4bd4b2f000f33a76bfd5d2f9b1a2850", + "semantic_hash": "" + }, + "docs/wiki/pages/B08_wf5_Quantity/B08_db.md": { + "mtime": 1783850708.0, + "ast_hash": "00ca815731f29da6f062f6e5884430de", + "semantic_hash": "" + }, + "docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": { + "mtime": 1784259186.086623, + "ast_hash": "d7dfc0b92c6297f7fa95444c2916461e", + "semantic_hash": "" + }, + "docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": { + "mtime": 1783844389.0, + "ast_hash": "c17bf17a317f2190131be87d2e23e9bb", + "semantic_hash": "" + }, + "docs/wiki/concepts/design.md": { + "mtime": 1784260030.4069157, + "ast_hash": "85aec17d904c548e299a4e3810669cc0", + "semantic_hash": "" + } +} \ No newline at end of file diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index a73144cb..4e08ab9c 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -298,10 +298,6 @@ export function showToast(message: string, kind: ToastKind = "info", durationMs export interface WorkflowShellOptions { /** 상단 페이지 타이틀 (i18n 결과) */ title: string; - /** 상단 진행 단계 라벨 배열 (i18n 결과) */ - steps?: string[]; - /** 현재 활성 단계 인덱스 */ - activeStep?: number; } export interface WorkflowShellHandle { @@ -315,23 +311,7 @@ export interface WorkflowShellHandle { export function createWorkflowShell(opts: WorkflowShellOptions): WorkflowShellHandle { // 상단 헤더 const titleEl = el("h2", { className: "ui-wf__title", text: opts.title }); - const headerChildren: HTMLElement[] = [titleEl]; - - if (opts.steps && opts.steps.length > 0) { - const stepsEl = el("div", { className: "ui-wf__steps" }); - opts.steps.forEach((label, idx) => { - const isActive = idx === (opts.activeStep ?? 0); - stepsEl.append( - el("span", { - className: `ui-wf__step${isActive ? " is-active" : ""}`, - text: label, - }), - ); - }); - headerChildren.push(stepsEl); - } - - const header = el("header", { className: "ui-wf__header", children: headerChildren }); + const header = el("header", { className: "ui-wf__header", children: [titleEl] }); const leftPanel = el("div", { className: "ui-wf__left" }); const rightArea = el("div", { className: "ui-wf__right" }); const bodyRow = el("div", { className: "ui-wf__body", children: [leftPanel, rightArea] }); @@ -523,6 +503,8 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement { const BASE_STYLE_ID = "ui-template-elements-style"; const BASE_CSS = ` +[hidden] { display: none !important; } + /* --- Button --- */ .ui-btn { display: inline-flex; @@ -721,19 +703,6 @@ const BASE_CSS = ` background-color: var(--color-surface-raised); } .ui-wf__title { font-size: var(--text-subheading); } -.ui-wf__steps { display: flex; gap: var(--spacing-8); } -.ui-wf__step { - padding: var(--spacing-4) var(--spacing-16); - border-radius: var(--radius-pills); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-slate); - background-color: var(--color-paper); -} -.ui-wf__step.is-active { - color: var(--color-royal-amethyst); - background-color: var(--color-mist-violet); -} .ui-wf__body { display: flex; flex: 1; min-height: 0; } .ui-wf__left { width: var(--wf-left-panel-width); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 3d32c03e..11dcacfb 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -102,6 +102,11 @@ export const ui_locales = { WF_Step_DesignDetail: ["상세 설계", "Detailed Design"], WF_Step_Quantity: ["수량 산출", "Quantity Takeoff"], WF_Step_Estimation: ["견적 / 문서", "Estimation / Docs"], + WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"], + WF_State_Failed: ["실패", "Failed"], + WF_State_Complete: ["완료", "Complete"], + WF_State_InProgress: ["진행 중", "In Progress"], + WF_State_NotStarted: ["미실행", "Not Started"], /* --------------------------------------------------------------------------- * 앱 셸 — 헤더 / 푸터 (공통 네비게이션) @@ -289,6 +294,9 @@ export const ui_locales = { A06_Login_Field_Otp_Placeholder: ["6자리 코드를 입력하세요", "Enter the 6-digit code"], A06_Login_Verify: ["인증하고 로그인", "Verify and sign in"], A06_Login_OtpSent: ["인증 코드를 이메일로 발송했습니다.", "A verification code was emailed."], + A06_Login_OtpResend: ["인증 코드 재발송", "Resend verification code"], + A06_Login_OtpResendCountdown: ["{seconds}초 후 재발송", "Resend in {seconds}s"], + A06_Login_OtpBack: ["이메일/비밀번호 다시 입력", "Re-enter email/password"], A06_Login_Success: ["로그인되었습니다.", "Signed in."], A06_Login_Error_Request: ["로그인 요청에 실패했습니다.", "Sign-in request failed."], diff --git a/ui_template/ui_template_workflow_layout.css b/ui_template/ui_template_workflow_layout.css index 0f205681..4734a3a6 100644 --- a/ui_template/ui_template_workflow_layout.css +++ b/ui_template/ui_template_workflow_layout.css @@ -93,6 +93,12 @@ overflow-x: auto; } +.ui-workflow-layout__steps.is-compact { + flex-wrap: wrap; + gap: var(--spacing-4); + overflow: visible; +} + .ui-workflow-layout__step { flex: 0 0 auto; padding: var(--spacing-4) var(--spacing-16); @@ -116,6 +122,12 @@ background: var(--color-paper-hover, var(--color-paper)); } +.ui-workflow-layout__steps.is-compact .ui-workflow-layout__step { + height: 28px; + padding: 0 var(--spacing-12); + white-space: nowrap; +} + .ui-workflow-layout__step.is-active { background: var(--color-mist-violet); color: var(--color-accent); diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 4b841d9a..c3106cf1 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -1,4 +1,5 @@ import "./ui_template_workflow_layout.css"; +import { t } from "./ui_template_locale"; export interface WorkflowStage { stage_no: number; @@ -13,7 +14,8 @@ export interface WorkflowLayoutOptions { leftPanel: HTMLElement; mainContent: HTMLElement; stages?: WorkflowStage[]; - routes?: string[]; + currentStage?: number; + routes?: readonly string[]; onStepClick?: (stepIndex: number, route: string) => void; onMenuToggle?: (isOpen: boolean) => void; onHeaderToggle?: (isVisible: boolean) => void; @@ -25,17 +27,22 @@ export interface WorkflowLayoutHandle { setHeaderVisible: (isVisible: boolean) => void; } -function createStepBar( +export interface StepBarOptions { + stages?: WorkflowStage[]; + currentStage?: number; + routes?: readonly string[]; + compact?: boolean; + onStepClick?: (stepIndex: number, route: string) => void; +} + +export function createStepBar( steps: readonly string[], activeStep: number, - options?: { - stages?: WorkflowStage[]; - routes?: string[]; - onStepClick?: (stepIndex: number, route: string) => void; - }, + options?: StepBarOptions, ): HTMLElement { const bar = document.createElement("div"); bar.className = "ui-workflow-layout__steps"; + if (options?.compact) bar.classList.add("is-compact"); steps.forEach((step, index) => { const button = document.createElement("button"); button.type = "button"; @@ -43,30 +50,32 @@ function createStepBar( if (index === activeStep) button.classList.add("is-active"); button.textContent = step; - // 스텝바는 항상 자유롭게 이동 가능(게이팅하지 않음). - // 단계 완료/무효화 판정은 사용자가 각 페이지의 액션 버튼(업로드·분석 실행 등)을 - // 눌렀을 때 백엔드가 계산·DB 갱신하며, 여기서는 그 결과를 색상/툴팁으로 표시만 한다. - button.classList.add("is-enabled"); + 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; + button.classList.toggle("is-enabled", isEnabled); + button.disabled = !isEnabled; // state 기반 스타일링 (표시 전용) - if (options?.stages && options.stages[index]) { - const state = options.stages[index].state; + if (stage) { + const state = stage.state; button.classList.add(`state-${state.toLowerCase()}`); if (state === "STALE") { - button.title = "Stale (하위 단계 변경으로 무효화됨)"; + button.title = t("WF_State_Stale"); } else if (state === "FAILED") { - button.title = "Failed (실패)"; + button.title = t("WF_State_Failed"); } else if (state === "COMPLETE") { - button.title = "Complete (완료)"; + button.title = t("WF_State_Complete"); } else if (state === "IN_PROGRESS") { - button.title = "In Progress (진행 중)"; + button.title = t("WF_State_InProgress"); } else { - button.title = "Not Started (미실행)"; + button.title = t("WF_State_NotStarted"); } } - // 클릭 이벤트 — 모든 스텝에서 이동 허용 - if (options?.routes && options?.routes[index]) { + if (isEnabled && options?.routes?.[index]) { button.addEventListener("click", () => { options.onStepClick?.(index, options.routes![index]); }); @@ -99,6 +108,7 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa title, createStepBar(options.steps, options.activeStep, { stages: options.stages, + currentStage: options.currentStage, routes: options.routes, onStepClick: options.onStepClick, }),