260717_0
This commit is contained in:
@@ -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 공용.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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))]),
|
||||
);
|
||||
}
|
||||
@@ -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) : "-";
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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) : "-";
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user