This commit is contained in:
2026-07-17 12:51:47 +09:00
parent 38af2aab9f
commit 4ac681bd10
30 changed files with 1340 additions and 803 deletions
+34 -12
View File
@@ -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<void> {
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 공용.
+40
View File
@@ -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<WorkflowState> {
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);
}
+28 -10
View File
@@ -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")
+91 -5
View File
@@ -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<HTMLElement>(".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<void> {
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<void> {
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",
+8
View File
@@ -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);
+15 -6
View File
@@ -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
+5 -4
View File
@@ -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),
)
+64
View File
@@ -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))]),
);
}
+122
View File
@@ -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<unknown>): Promise<void> {
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) : "-";
}
+148
View File
@@ -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<void> {
await runRequest(() =>
systemMode && action === "APPROVE"
? import("./B01_Dashboard_Api_Fetch").then((api) => api.systemApproveJoinRequest(requestId))
: processJoinRequest(requestId, action),
);
window.dispatchEvent(new HashChangeEvent("hashchange"));
}
+20 -563
View File
@@ -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<void> {
}
}
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<void> {
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<unknown>): Promise<void> {
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) : "-";
}
+109
View File
@@ -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;
}
@@ -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]),
},
);
}
-24
View File
@@ -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));
+32 -28
View File
@@ -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<void> {
const slots = initializeSlots();
const cardMap = new Map<FileSlot, HTMLElement>();
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();
+16 -25
View File
@@ -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<void> {
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<void> {
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]);
}
},
});
+17 -26
View File
@@ -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<void> {
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<void> {
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]);
}
},
});
@@ -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<void> {
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<void> {
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]);
}
},
});
@@ -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<void> {
await renderPendingWorkflow(root, {
title: L("B07_Design_Title"),
steps: workflowSteps(),
activeStep: 4,
+3 -3
View File
@@ -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<void> {
await renderPendingWorkflow(root, {
title: L("B08_Quantity_Title"),
steps: workflowSteps(),
activeStep: 5,
@@ -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<void> {
await renderPendingWorkflow(root, {
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
+26
View File
@@ -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,
+36 -11
View File
@@ -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()
+1
View File
@@ -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"
@@ -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;
+352
View File
@@ -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": ""
}
}
+3 -34
View File
@@ -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);
+8
View File
@@ -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."],
@@ -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);
+30 -20
View File
@@ -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,
}),