feat(quant): WBS-FE-BE-100 complete Vue3 Vite8 SPA & .NET10 FastEndpoints refactoring
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Failing after 13s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 19s

This commit is contained in:
2026-07-22 15:14:28 +09:00
parent fd8ff3d51e
commit 2fe4cb288f
100 changed files with 7975 additions and 804 deletions
@@ -1,7 +1,7 @@
@page "/Account/Login"
@model QuantEngine.Web.Pages.Account.LoginModel
@{
ViewData["Title"] = "로그인 - QuantEngine";
ViewData["Title"] = "로그인 - QuantEngine ERP";
}
<!DOCTYPE html>
@@ -10,264 +10,155 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
background-color: var(--douzone-navy);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0b16 0%, #13152e 100%);
padding: 20px;
margin: 0;
}
.login-container {
.douzone-login-card {
width: 100%;
max-width: 480px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 48px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
max-width: 440px;
background: #FFFFFF;
border-radius: 6px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.login-header {
.login-card-header {
background-color: var(--douzone-slate);
color: #FFFFFF;
padding: 24px 32px;
text-align: center;
margin-bottom: 40px;
border-bottom: 3px solid #1A252F;
}
.login-avatar {
width: 56px;
height: 56px;
background: #3f51b5;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: bold;
margin: 0 auto 16px;
.login-card-header h2 {
margin: 0 0 6px 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.login-title {
color: white;
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
}
.login-subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
.login-card-header p {
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
}
.form-input {
background-color: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #ffffff;
padding: 12px 14px;
font-size: 14px;
transition: all 0.2s ease;
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.form-input:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.12);
border-color: rgba(63, 81, 181, 0.8);
box-shadow: 0 0 0 3px rgba(63, 81, 181, 0.2);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0;
}
.checkbox-input {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #3f51b5;
}
.checkbox-label {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
cursor: pointer;
}
.alert {
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
display: none;
}
.alert.show {
display: block;
}
.alert-error {
background-color: rgba(244, 67, 54, 0.15);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #ff7675;
}
.alert-success {
background-color: rgba(76, 175, 80, 0.15);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #81c784;
}
.btn {
padding: 12px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #3f51b5;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #5566cc;
box-shadow: 0 8px 24px rgba(63, 81, 181, 0.4);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 16px;
margin-top: 24px;
color: #BDC3C7;
}
.login-footer p {
margin: 0;
.login-card-body {
padding: 32px;
}
@@media (max-width: 480px) {
.login-container {
padding: 32px 20px;
}
.form-group-douzone {
margin-bottom: 20px;
}
.login-title {
font-size: 24px;
}
.form-group-douzone label {
display: block;
font-weight: 700;
font-size: 12px;
margin-bottom: 6px;
color: var(--douzone-navy);
}
.btn-douzone-login {
width: 100%;
background-color: var(--douzone-navy);
color: #FFFFFF;
font-weight: 700;
font-size: 14px;
padding: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-douzone-login:hover {
background-color: #1A252F;
}
.alert-douzone-error {
background-color: var(--status-error-bg);
color: var(--status-error-text);
border: 1px solid var(--status-error);
padding: 10px 14px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<div class="login-avatar">Q</div>
<h1 class="login-title">QuantEngine</h1>
<p class="login-subtitle">은퇴자산포트폴리오 우자 관리 시스템</p>
<div class="douzone-login-card">
<div class="login-card-header">
<h2>QuantEngine ERP</h2>
<p>은퇴자산 포트폴리오 투자 관리 전용 시스템</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-error show">
<strong>오류:</strong> @Model.ErrorMessage
</div>
}
<div class="login-card-body">
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert-douzone-error">
<strong>오류:</strong> @Model.ErrorMessage
</div>
}
<form method="post" class="login-form">
@Html.AntiForgeryToken()
<div class="form-group">
<label for="username" class="form-label">관리자 아이디</label>
<input
type="text"
id="username"
name="username"
value="@Model.Username"
class="form-input"
placeholder="아이디를 입력하세요"
required />
</div>
<form method="post">
@Html.AntiForgeryToken()
<div class="form-group-douzone">
<label for="username">관리자 아이디</label>
<input
type="text"
id="username"
name="username"
value="@Model.Username"
class="form-control"
placeholder="아이디를 입력하세요 (Enter 이동)"
required
autofocus />
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="비밀번호를 입력하세요"
required />
</div>
<div class="form-group-douzone">
<label for="password">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="form-control"
placeholder="비밀번호를 입력하세요"
required />
</div>
<div class="form-checkbox">
<input
type="checkbox"
id="rememberUsername"
name="rememberUsername"
@(Model.RememberUsername ? "checked" : "")
class="checkbox-input" />
<label for="rememberUsername" class="checkbox-label">
다음에 아이디 자동 입력
</label>
</div>
<div class="form-group-douzone" style="display: flex; align-items: center; gap: 8px;">
<input
type="checkbox"
id="rememberUsername"
name="rememberUsername"
@(Model.RememberUsername ? "checked" : "") />
<label for="rememberUsername" style="margin: 0; cursor: pointer; font-weight: normal;">
아이디 자동 저장
</label>
</div>
<button type="submit" class="btn btn-primary" id="loginBtn">
로그인
</button>
</form>
<button type="submit" class="btn-douzone-login" id="loginBtn">
로그인 (Enter)
</button>
</form>
</div>
<div class="login-footer">
<p>© 2026 QuantEngine. 모든 권리 예약.</p>
<p style="font-size: 11px; margin-top: 4px; opacity: 0.8;">Version: @Model.AppVersion</p>
<div style="background-color: #ECF0F1; padding: 12px; text-align: center; font-size: 11px; color: #7F8C8D; border-top: 1px solid #BDC3C7;">
<span><span class="hotkey-badge">Enter</span> 다음 필드 이동</span>
<span style="margin-left: 12px;">© 2026 QuantEngine v@Model.AppVersion</span>
</div>
</div>
<script src="~/js/douzone-keyboard.js" asp-append-version="true"></script>
</body>
</html>
@@ -2,99 +2,111 @@
@model QuantEngine.Web.Pages.Admin.Dashboard.IndexModel
@{
ViewData["Title"] = "대시보드";
Layout = "_AdminLayout";
}
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">대시보드</h1>
<p class="page-subtitle">QuantEngine 시스템 개요 및 상태</p>
</div>
<!-- Douzone Type 6: Tabbed Single Viewport Dashboard (Anti-Scroll Policy) -->
<div class="douzone-viewport-container d-flex flex-column h-100">
<!-- Douzone Tab Navigation Header -->
<ul class="nav nav-tabs bg-light px-2 pt-2 border-bottom mb-2" role="tablist">
<li class="nav-item">
<button class="nav-link active fw-bold text-navy py-1 px-3" id="tab-kpi" data-bs-toggle="tab" data-bs-target="#kpi-pane" type="button" role="tab">
<i class="ti ti-chart-line me-1"></i> 핵심 자산 KPI
</button>
</li>
<li class="nav-item">
<button class="nav-link fw-bold text-navy py-1 px-3" id="tab-actions" data-bs-toggle="tab" data-bs-target="#actions-pane" type="button" role="tab">
<i class="ti ti-bolt me-1"></i> 빠른 제어
</button>
</li>
<li class="nav-item">
<button class="nav-link fw-bold text-navy py-1 px-3" id="tab-system" data-bs-toggle="tab" data-bs-target="#system-pane" type="button" role="tab">
<i class="ti ti-server me-1"></i> 시스템 가동 상태
</button>
</li>
</ul>
<!-- Stats Row -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-card-label">활성 사용자</div>
<div class="stat-card-number">@(Model.ActiveUsersCount ?? 0)</div>
<small class="text-muted">등록된 관리자</small>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-card-label">최근 수집</div>
<div class="stat-card-number">@(Model.RecentRunsCount ?? 0)</div>
<small class="text-muted">데이터 수집 실행</small>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-card-label">데이터베이스</div>
<div class="stat-card-number">
@if (Model.IsDatabaseConnected)
{
<span class="status-dot active"></span><text>연결됨</text>
}
else
{
<span class="status-dot" style="background-color:#e74c3c;"></span><text>연결 끊김</text>
}
<!-- Tab Contents (No Vertical Page Scroll) -->
<div class="tab-content flex-grow-1 overflow-hidden p-2">
<!-- Pane 1: Asset KPI Summary -->
<div class="tab-pane fade show active h-100" id="kpi-pane" role="tabpanel">
<div class="row g-2">
<div class="col-md-4">
<div class="card p-3 border shadow-sm h-100">
<span class="text-muted small fw-bold">등록 관리자</span>
<h2 class="text-navy my-1">@(Model.ActiveUsersCount ?? 0) 명</h2>
<span class="chip-status chip-status-pass d-inline-block">PASS</span>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 border shadow-sm h-100">
<span class="text-muted small fw-bold">수집 실행 이력</span>
<h2 class="text-navy my-1">@(Model.RecentRunsCount ?? 0) 회</h2>
<span class="chip-status chip-status-pass d-inline-block">정상 작동</span>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 border shadow-sm h-100">
<span class="text-muted small fw-bold">PostgreSQL DB 연결</span>
<h2 class="my-1">
@if (Model.IsDatabaseConnected)
{
<span class="text-success fw-bold">연결됨</span>
}
else
{
<span class="text-danger fw-bold">연결 끊김</span>
}
</h2>
<span class="chip-status @(Model.IsDatabaseConnected ? "chip-status-pass" : "chip-status-error") d-inline-block">
@(Model.IsDatabaseConnected ? "PostgreSQL 3NF PASS" : "DB 통신 실패")
</span>
</div>
</div>
</div>
<small class="text-muted">@(Model.IsDatabaseConnected ? "PostgreSQL 정상" : "데이터 조회 실패 - 로그 확인 필요")</small>
</div>
</div>
</div>
<!-- Main Content Row -->
<div class="row">
<!-- Quick Actions Card -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">빠른 작업</h3>
</div>
<div class="card-body">
<div class="mb-3">
<a href="/Admin/Collection" class="btn btn-sm btn-primary w-100">
<i class="ti ti-database me-1"></i> 데이터 수집 시작
<!-- Pane 2: Quick Actions -->
<div class="tab-pane fade h-100" id="actions-pane" role="tabpanel">
<div class="card p-3 border shadow-sm h-100">
<h5 class="fw-bold text-navy mb-3"><i class="ti ti-dashboard me-1"></i>엔진 빠른 실행</h5>
<div class="d-flex gap-2 mb-3">
<a href="/Admin/Collection" class="btn btn-sm btn-primary py-2 px-3">
<i class="ti ti-database me-1"></i> 데이터 수집 시작 (F3)
</a>
<a href="/Admin/Users" class="btn btn-sm btn-outline-secondary py-2 px-3">
<i class="ti ti-users me-1"></i> 사용자 권한 설정
</a>
<a href="/Admin/Monitoring" class="btn btn-sm btn-outline-secondary py-2 px-3">
<i class="ti ti-eye me-1"></i> 실시간 모니터링
</a>
</div>
<div class="mb-3">
<a href="/Admin/Users" class="btn btn-sm btn-outline-primary w-100">
<i class="ti ti-users me-1"></i> 사용자 관리
</a>
</div>
<div class="mb-3">
<a href="/Admin/Monitoring" class="btn btn-sm btn-outline-primary w-100">
<i class="ti ti-eye me-1"></i> 모니터링 보기
</a>
</div>
<hr>
<div class="text-center">
<small class="text-muted">마지막 갱신: @DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")</small>
<div class="text-muted small">
마지막 갱신: @DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
</div>
</div>
</div>
</div>
<!-- System Info Card -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">시스템 정보</h3>
</div>
<div class="card-body">
<div class="mb-2">
<span class="text-muted">환경:</span>
<strong>@Model.EnvironmentName</strong>
</div>
<div class="mb-2">
<span class="text-muted">데이터베이스:</span>
<strong>@(Model.IsDatabaseConnected ? "연결됨" : "연결 끊김")</strong>
</div>
<div>
<span class="text-muted">배포 버전:</span>
<strong>@Model.AppVersion</strong>
</div>
<!-- Pane 3: System Status -->
<div class="tab-pane fade h-100" id="system-pane" role="tabpanel">
<div class="card p-3 border shadow-sm h-100">
<h5 class="fw-bold text-navy mb-3"><i class="ti ti-info-circle me-1"></i>서버 진단</h5>
<table class="douzone-table">
<tbody>
<tr>
<td class="fw-bold" style="width: 150px;">호스팅 환경</td>
<td>@Model.EnvironmentName</td>
</tr>
<tr>
<td class="fw-bold">DB 스토어 연결</td>
<td>@(Model.IsDatabaseConnected ? "PostgreSQL 3NF Active" : "Memory Storage Fallback")</td>
</tr>
<tr>
<td class="fw-bold">배포 엔진 버전</td>
<td>@Model.AppVersion</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -5,71 +5,65 @@
Layout = "_AdminLayout";
}
<div class="row">
<!-- Left panel: Table list -->
<!-- Douzone Type 2: Master-Detail Split View (30% Left Table List : 70% Right High-Density Grid) -->
<div class="row g-2">
<!-- Left Panel: Table List (30%) -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h3 class="card-title">데이터베이스 테이블 목록</h3>
<div class="card shadow-sm border-0">
<div class="card-header bg-dark text-white py-2">
<h4 class="card-title text-warning m-0 fs-5"><i class="ti ti-database me-1"></i>PostgreSQL 스토어 테이블</h4>
</div>
<div class="list-group list-group-flush" style="max-height: 700px; overflow-y: auto;">
<div class="list-group list-group-flush douzone-table-list" style="max-height: 680px; overflow-y: auto;">
@foreach (var table in Model.TableList)
{
var parts = table.Split('.');
var schema = parts[0];
var name = parts[1];
var isActive = Model.SelectedTable == table ? "active" : "";
var isActive = Model.SelectedTable == table ? "active bg-primary text-white" : "";
<a href="/Admin/Database?tableName=@table" class="list-group-item list-group-item-action @isActive d-flex justify-content-between align-items-center">
<a href="/Admin/Database?tableName=@table" class="list-group-item list-group-item-action @isActive py-2 px-3 d-flex justify-content-between align-items-center">
<div>
<span class="text-muted small">@schema.ToUpperInvariant().</span><strong>@name</strong>
<span class="opacity-75 small me-1">@schema.ToUpperInvariant().</span><strong>@name</strong>
</div>
<i class="ti ti-chevron-right text-muted"></i>
<i class="ti ti-chevron-right opacity-50"></i>
</a>
}
</div>
</div>
</div>
<!-- Right panel: Selected table data and CRUD actions -->
<!-- Right Panel: High-Density Table View & Controls (70%) -->
<div class="col-md-9">
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible" role="alert">
<div class="d-flex">
<div><i class="ti ti-alert-triangle me-2"></i></div>
<div>@Model.ErrorMessage</div>
</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
<div class="alert-douzone-error mb-2">
<strong>오류:</strong> @Model.ErrorMessage
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible" role="alert">
<div class="d-flex">
<div><i class="ti ti-circle-check me-2"></i></div>
<div>@Model.SuccessMessage</div>
</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
<div class="alert alert-success py-2 mb-2">
<strong>성공:</strong> @Model.SuccessMessage
</div>
}
@if (!string.IsNullOrEmpty(Model.SelectedTable))
{
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<div>
<h3 class="card-title">@Model.SelectedTable 데이터 조회</h3>
<p class="card-subtitle text-muted">상위 100개 데이터 행을 출력합니다.</p>
<h4 class="card-title text-navy m-0 fw-bold">@Model.SelectedTable 데이터</h4>
<span class="text-muted fs-6">상위 100개 행 고밀도 출력 (F7 엑셀 다운로드)</span>
</div>
<div>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#modal-add-row">
<i class="ti ti-plus me-1"></i> 데이터 추가
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#modal-add-row">
<i class="ti ti-plus me-1"></i> 데이터 추가 (F4)
</button>
</div>
</div>
<div class="table-responsive" style="max-height: 600px;">
<table class="table table-vcenter table-mobile-md card-table">
<div class="table-responsive douzone-grid-body p-0" style="max-height: 600px;">
<table class="douzone-table">
<thead>
<tr>
@foreach (var col in Model.ColumnNames)
@@ -78,53 +72,58 @@
@col
@if (col == Model.PrimaryKeyColumn)
{
<span class="badge bg-purple-lt ms-1">PK</span>
<span class="badge bg-warning text-dark ms-1">PK</span>
}
</th>
}
<th class="w-1">작업</th>
<th style="width: 120px; text-align: center;">작업</th>
</tr>
</thead>
<tbody>
@if (Model.Rows.Count > 0)
@if (Model.Rows.Count == 0)
{
@foreach (var row in Model.Rows)
{
var pkVal = Model.PrimaryKeyColumn != null && row.ContainsKey(Model.PrimaryKeyColumn)
? row[Model.PrimaryKeyColumn]?.ToString() ?? ""
: "";
<tr>
@foreach (var col in Model.ColumnNames)
{
<td data-label="@col">
<span class="text-wrap">@row[col]</span>
</td>
}
<td>
<div class="btn-list flex-nowrap">
<button class="btn btn-sm btn-outline-primary edit-row-btn"
data-bs-toggle="modal"
data-bs-target="#modal-edit-row"
data-pk-val="@pkVal"
@foreach (var col in Model.ColumnNames)
{
@:data-field-@col="@row[col]"
}>
수정
</button>
</div>
</td>
</tr>
}
<tr>
<td colspan="@(Model.ColumnNames.Count + 1)" class="text-center py-4 text-muted">
데이터가 존재하지 않습니다.
</td>
</tr>
}
else
{
<tr>
<td colspan="@(Model.ColumnNames.Count + 1)" class="text-center text-muted py-4">
데이터가 없습니다.
</td>
</tr>
@foreach (var row in Model.Rows)
{
var pkVal = Model.PrimaryKeyColumn != null && row.ContainsKey(Model.PrimaryKeyColumn) ? row[Model.PrimaryKeyColumn]?.ToString() : "";
<tr>
@foreach (var col in Model.ColumnNames)
{
var val = row.ContainsKey(col) ? row[col] : null;
<td>
@if (val != null && val.ToString()?.StartsWith("{") == true)
{
<span class="font-monospace small text-truncate d-inline-block" style="max-width: 250px;" title="@val">@val</span>
}
else
{
@val
}
</td>
}
<td class="text-center">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 me-1 btn-edit-row"
data-pk-val="@pkVal"
data-bs-toggle="modal"
data-bs-target="#modal-edit-row">
수정
</button>
<form method="post" asp-page-handler="DeleteRow" class="d-inline" onsubmit="return confirm('정말 삭제하시겠습니까?');">
@Html.AntiForgeryToken()
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
<input type="hidden" name="primaryKeyValue" value="@pkVal" />
<button type="submit" class="btn btn-xs btn-outline-danger py-0 px-1">삭제</button>
</form>
</td>
</tr>
}
}
</tbody>
</table>
@@ -133,123 +132,48 @@
}
else
{
<div class="card card-md">
<div class="card-body text-center py-5">
<div class="mb-3 text-muted">
<i class="ti ti-database-off" style="font-size: 3rem;"></i>
</div>
<h3>선택된 테이블이 없습니다</h3>
<p class="text-muted">좌측 목록에서 조회 및 수정을 원하는 테이블을 선택해 주세요.</p>
</div>
<div class="card p-5 text-center text-muted">
<i class="ti ti-arrow-left fs-1 mb-2"></i>
좌측 목록에서 조회할 데이터베이스 테이블을 선택하세요.
</div>
}
</div>
</div>
<!-- Modal: Add Row -->
@if (!string.IsNullOrEmpty(Model.SelectedTable))
{
<!-- Modal: Add Row -->
<div class="modal modal-blur fade" id="modal-add-row" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal fade" id="modal-add-row" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="post" asp-page-handler="AddRow">
@Html.AntiForgeryToken()
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
<div class="modal-header">
<h5 class="modal-title">새 데이터 행 추가 (@Model.SelectedTable)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="modal-header bg-navy text-white py-2">
<h5 class="modal-title">@Model.SelectedTable 신규 데이터 추가</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="modal-body p-3">
<div class="row g-2">
@foreach (var col in Model.ColumnNames)
{
var isPk = col == Model.PrimaryKeyColumn;
var isSerial = col == "id" && Model.SelectedTable.Contains("workspace_change_log");
<div class="col-md-6 mb-3">
<label class="form-label">
@col
@if (isPk) { <span class="text-danger">* (PK)</span> }
</label>
@if (isSerial)
{
<input type="text" class="form-control" placeholder="자동 생성 (SERIAL)" disabled />
}
else
{
<input type="text" class="form-control" name="@col" placeholder="@col 값을 입력하세요" required="@isPk" />
}
if (col == Model.PrimaryKeyColumn && col.EndsWith("id", StringComparison.OrdinalIgnoreCase))
{
continue; // Auto-generated ID
}
<div class="col-md-6">
<label class="form-label small fw-bold mb-1">@col</label>
<input type="text" name="rowData[@col]" class="form-control form-control-sm" placeholder="Enter 포커스 이동" />
</div>
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">취소</button>
<button type="submit" class="btn btn-primary ms-auto">저장하기</button>
<div class="modal-footer py-2 bg-light">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">취소 (Esc)</button>
<button type="submit" class="btn btn-sm btn-primary">저장 (F4 / Enter)</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal: Edit Row -->
<div class="modal modal-blur fade" id="modal-edit-row" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<form method="post" asp-page-handler="SaveRow">
@Html.AntiForgeryToken()
<input type="hidden" name="tableName" value="@Model.SelectedTable" />
<input type="hidden" name="pkColumn" value="@Model.PrimaryKeyColumn" />
<input type="hidden" name="pkValue" id="edit-pk-value" />
<div class="modal-header">
<h5 class="modal-title">데이터 행 수정 (@Model.SelectedTable)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row" id="edit-fields-container">
@foreach (var col in Model.ColumnNames)
{
var isPk = col == Model.PrimaryKeyColumn;
<div class="col-md-6 mb-3">
<label class="form-label">
@col
@if (isPk) { <span class="text-muted">(PK - 수정 불가)</span> }
</label>
<input type="text" class="form-control" name="@col" id="edit-field-input-@col" @(isPk ? "readonly" : "") />
</div>
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">취소</button>
<button type="submit" class="btn btn-primary ms-auto">저장하기</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const editButtons = document.querySelectorAll(".edit-row-btn");
editButtons.forEach(btn => {
btn.addEventListener("click", function () {
const pkVal = this.getAttribute("data-pk-val");
document.getElementById("edit-pk-value").value = pkVal;
// Populate fields dynamically
Array.from(this.attributes).forEach(attr => {
if (attr.name.startsWith("data-field-")) {
const fieldName = attr.name.substring("data-field-".length);
const inputEl = document.getElementById("edit-field-input-" + fieldName);
if (inputEl) {
inputEl.value = attr.value;
}
}
});
});
});
});
</script>
}
@@ -0,0 +1,184 @@
@page
@model QuantEngine.Web.Pages.Admin.Decisions.DetailModel
@{
ViewData["Title"] = "의사결정 상세 폭포수 분석";
Layout = "_AdminLayout";
var decision = Model.Decision;
var id = decision?.TryGetValue("id", out var valId) == true ? valId?.ToString() : "";
var decId = decision?.TryGetValue("decision_id", out var valDec) == true ? valDec?.ToString() : "";
var decidedAt = decision?.TryGetValue("decided_at", out var valAt) == true ? valAt?.ToString() : "";
var ticker = decision?.TryGetValue("instrument_id", out var valTick) == true ? valTick?.ToString() : "";
var action = decision?.TryGetValue("action", out var valAct) == true ? valAct?.ToString()?.ToUpperInvariant() : "";
var gate = decision?.TryGetValue("gate", out var valGate) == true ? valGate?.ToString()?.ToUpperInvariant() : "";
var score = decision?.TryGetValue("score", out var valScore) == true ? valScore?.ToString() : "";
var version = decision?.TryGetValue("source_version", out var valVer) == true ? valVer?.ToString() : "";
}
<div class="container-xl">
<!-- Page header -->
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">
의사결정 폭포수 상세 분석
</h2>
<div class="text-muted mt-1">의사결정 ID #@id 에 대한 개별 리스크 게이트 차단 사유와 증빙 데이터(Provenance)를 시각화합니다.</div>
</div>
<div class="col-auto ms-auto">
<a href="/Admin/Decisions" class="btn btn-outline-secondary">
<i class="ti ti-arrow-left me-1"></i> 목록으로 돌아가기
</a>
</div>
</div>
</div>
<div class="row row-cards mt-3">
<!-- Summary Card -->
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">의사결정 기본 요약</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label text-muted">종목 Ticker</label>
<div class="h2">@ticker</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">최종 실행 결정 (Action)</label>
<div>
@if (action == "BUY")
{
<span class="badge bg-success-lt px-3 py-2 fs-5">매수 (BUY)</span>
}
else if (action == "SELL")
{
<span class="badge bg-danger-lt px-3 py-2 fs-5">매도 (SELL)</span>
}
else if (action == "REJECT" || action == "BLOCKED")
{
<span class="badge bg-warning-lt px-3 py-2 fs-5">거부 (REJECT)</span>
}
else
{
<span class="badge bg-secondary-lt px-3 py-2 fs-5">관망 (HOLD)</span>
}
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">리스크 게이트 통과 여부</label>
<div>
@if (gate == "PASS")
{
<span class="badge bg-success px-3 py-2 fs-5">PASS (통과)</span>
}
else
{
<span class="badge bg-danger px-3 py-2 fs-5">BLOCKED (차단됨)</span>
}
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">결정 점수</label>
<div class="h3 fw-bold">@score 점</div>
</div>
<div class="mb-3">
<label class="form-label text-muted">결정 일시</label>
<div>@decidedAt</div>
</div>
<div>
<label class="form-label text-muted">엔진 버전</label>
<code>@version</code>
</div>
</div>
</div>
</div>
<!-- Waterfall Card -->
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">의사결정 게이트 폭포수 (Waterfall Gate Analysis)</h3>
</div>
<div class="card-body">
<p class="text-muted">퀀트 엔진의 단방향 데이터 흐름을 따르는 단계별 순차 검증(Waterfall) 평가 로그입니다. 특정 게이트에서 BLOCKED 처리되면 하위 매수 진입 프로세스가 차단됩니다.</p>
<div class="list-group list-group-flush list-group-hoverable">
@{
bool foundGates = false;
}
@if (Model.ProvenanceJson.ValueKind == System.Text.Json.JsonValueKind.Object)
{
foreach (var prop in Model.ProvenanceJson.EnumerateObject())
{
var name = prop.Name;
var valStr = prop.Value.ToString();
var isGate = name.Contains("gate") || name.Contains("sfg") || valStr == "PASS" || valStr == "BLOCKED" || valStr == "TRIGGERED";
if (isGate)
{
foundGates = true;
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
@if (valStr == "PASS" || valStr.ToLowerInvariant() == "true" || valStr == "OK")
{
<span class="badge bg-success me-1"></span>
}
else if (valStr == "BLOCKED" || valStr.ToLowerInvariant() == "false" || valStr == "TRIGGERED")
{
<span class="badge bg-danger me-1"></span>
}
else
{
<span class="badge bg-warning me-1"></span>
}
</div>
<div class="col text-truncate">
<div class="text-body d-block fw-bold">@name</div>
<small class="text-muted text-wrap d-block">값(Value): @valStr</small>
</div>
<div class="col-auto">
@if (valStr == "PASS" || valStr.ToLowerInvariant() == "true" || valStr == "OK")
{
<span class="badge bg-success-lt px-2 py-1">통과 (PASS)</span>
}
else if (valStr == "BLOCKED" || valStr.ToLowerInvariant() == "false" || valStr == "TRIGGERED")
{
<span class="badge bg-danger-lt px-2 py-1">차단 (BLOCKED)</span>
}
else
{
<span class="badge bg-warning-lt px-2 py-1">경고/관망 (@valStr)</span>
}
</div>
</div>
</div>
}
}
}
@if (!foundGates)
{
<div class="text-center py-4 text-muted">
<i class="ti ti-info-circle fs-3 d-block mb-1"></i>
세부 게이트 평가 지표가 JSON 원장에 기록되지 않았습니다.
</div>
}
</div>
</div>
</div>
<!-- Provenance Ledger Card -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">원시 JSON 감시 원장 (Raw Provenance Ledger)</h3>
</div>
<div class="card-body">
<pre class="bg-light p-3 rounded"><code>@System.Text.Json.JsonSerializer.Serialize(Model.ProvenanceJson, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })</code></pre>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Infrastructure.Data;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Decisions;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class DetailModel : PageModel
{
private readonly IDbConnectionFactory _connectionFactory;
public IDictionary<string, object?>? Decision { get; private set; }
public JsonElement ProvenanceJson { get; private set; }
public DetailModel(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IActionResult> OnGetAsync(long id)
{
using var conn = _connectionFactory.CreateConnection();
conn.Open();
var row = await conn.QueryFirstOrDefaultAsync(@"
SELECT id, decision_id, decided_at, instrument_id, action, gate, score, source_version, provenance, created_at
FROM engine_history.decision_result_history
WHERE id = @Id", new { Id = id });
if (row == null)
{
return RedirectToPage("/Admin/Decisions/Index");
}
Decision = (IDictionary<string, object?>)row;
string provenanceStr = row.provenance?.ToString() ?? "{}";
try
{
ProvenanceJson = JsonDocument.Parse(provenanceStr).RootElement;
}
catch
{
ProvenanceJson = JsonDocument.Parse("{}").RootElement;
}
return Page();
}
}
@@ -0,0 +1,110 @@
@page
@model QuantEngine.Web.Pages.Admin.Decisions.IndexModel
@{
ViewData["Title"] = "의사결정 이력 관리";
Layout = "_AdminLayout";
}
<div class="container-xl">
<!-- Page header -->
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">
의사결정 이력 모니터링
</h2>
<div class="text-muted mt-1">퀀트 엔진의 최종 판단 및 투자 자격 검증 이력을 실시간 조회합니다.</div>
</div>
</div>
</div>
<!-- Content Card -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">의사결정 리스트</h3>
</div>
<div class="table-responsive">
<table class="table card-table table-vcenter text-nowrap datatable">
<thead>
<tr>
<th>결정 ID</th>
<th>결정 일시</th>
<th>종목 코드 (Ticker)</th>
<th>결정 액션 (Action)</th>
<th>게이트 상태 (Gate)</th>
<th>의사결정 점수 (Score)</th>
<th>수정자 버전</th>
<th class="w-1">폭포수 분석</th>
</tr>
</thead>
<tbody>
@if (Model.Decisions == null || !Model.Decisions.Any())
{
<tr>
<td colspan="8" class="text-center text-muted py-4">
기록된 의사결정 내역이 존재하지 않습니다.
</td>
</tr>
}
else
{
foreach (var row in Model.Decisions)
{
var id = row.TryGetValue("id", out var valId) ? valId?.ToString() : "";
var decId = row.TryGetValue("decision_id", out var valDec) ? valDec?.ToString() : "";
var decidedAt = row.TryGetValue("decided_at", out var valAt) ? valAt?.ToString() : "";
var ticker = row.TryGetValue("instrument_id", out var valTick) ? valTick?.ToString() : "";
var action = row.TryGetValue("action", out var valAct) ? valAct?.ToString()?.ToUpperInvariant() : "";
var gate = row.TryGetValue("gate", out var valGate) ? valGate?.ToString()?.ToUpperInvariant() : "";
var score = row.TryGetValue("score", out var valScore) ? valScore?.ToString() : "";
var version = row.TryGetValue("source_version", out var valVer) ? valVer?.ToString() : "";
<tr>
<td><span class="text-muted">#@id</span></td>
<td>@decidedAt</td>
<td><strong>@ticker</strong></td>
<td>
@if (action == "BUY")
{
<span class="badge bg-success-lt">매수 (BUY)</span>
}
else if (action == "SELL")
{
<span class="badge bg-danger-lt">매도 (SELL)</span>
}
else if (action == "REJECT" || action == "BLOCKED")
{
<span class="badge bg-warning-lt">거부 (REJECT)</span>
}
else
{
<span class="badge bg-secondary-lt">관망 (HOLD)</span>
}
</td>
<td>
@if (gate == "PASS")
{
<span class="badge bg-success">PASS</span>
}
else
{
<span class="badge bg-danger">BLOCKED</span>
}
</td>
<td>
<span class="fw-bold">@score 점</span>
</td>
<td><code>@version</code></td>
<td>
<a href="/Admin/Decisions/Detail?id=@id" class="btn btn-sm btn-outline-primary">
<i class="ti ti-git-fork me-1"></i> 분석 보기
</a>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Web.Services;
namespace QuantEngine.Web.Pages.Admin.Decisions;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
private readonly IPostgresqlHistoryStore _historyStore;
public IReadOnlyList<IDictionary<string, object?>> Decisions { get; private set; } = Array.Empty<IDictionary<string, object?>>();
public IndexModel(IPostgresqlHistoryStore historyStore)
{
_historyStore = historyStore;
}
public async Task OnGetAsync()
{
try
{
Decisions = await _historyStore.SnapshotAsync("decision_result_history", 100);
}
catch
{
Decisions = Array.Empty<IDictionary<string, object?>>();
}
}
}
@@ -10,121 +10,68 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - QuantEngine</title>
<title>@ViewData["Title"] - QuantEngine ERP</title>
<!-- Tabler CSS -->
<!-- Tabler CSS Core -->
<link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/css/tabler-vendors.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@@tabler/icons@latest/tabler-icons.css" rel="stylesheet" />
<!-- Custom Admin CSS -->
<!-- Douzone ERP Custom Theme CSS -->
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
</head>
<body>
<div class="page">
<!-- Sidebar (left) -->
<aside class="navbar navbar-vertical navbar-expand-lg navbar-dark" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="메뉴 토글">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark">
<a href="/Admin/Dashboard" class="d-flex align-items-center gap-2 text-decoration-none">
<i class="ti ti-chart-line" style="font-size: 1.5rem;"></i>
<span>QuantEngine</span>
</a>
</h1>
<div class="collapse navbar-collapse" id="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item">
<a class="nav-link @NavActive("/Admin/Dashboard")" href="/Admin/Dashboard">
<span class="nav-link-icon"><i class="ti ti-dashboard"></i></span>
<span class="nav-link-title">대시보드</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @NavActive("/Admin/Collection")" href="/Admin/Collection">
<span class="nav-link-icon"><i class="ti ti-database"></i></span>
<span class="nav-link-title">데이터 수집</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @NavActive("/Admin/Monitoring")" href="/Admin/Monitoring">
<span class="nav-link-icon"><i class="ti ti-eye"></i></span>
<span class="nav-link-title">모니터링</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @NavActive("/Admin/Users")" href="/Admin/Users">
<span class="nav-link-icon"><i class="ti ti-users"></i></span>
<span class="nav-link-title">사용자 관리</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @NavActive("/Admin/Operations")" href="/Admin/Operations">
<span class="nav-link-icon"><i class="ti ti-settings"></i></span>
<span class="nav-link-title">운영 관리</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link @NavActive("/Admin/Database")" href="/Admin/Database">
<span class="nav-link-icon"><i class="ti ti-table"></i></span>
<span class="nav-link-title">DB 테이블 관리</span>
</a>
</li>
</ul>
</div>
<div class="douzone-layout-container">
<!-- 1. Top Toolbar & Filter Header Bar -->
<header class="douzone-header-toolbar">
<div class="d-flex align-items-center gap-3">
<span class="fw-bold fs-3 text-warning">QuantEngine ERP</span>
<span class="border-start ps-3 opacity-75">@ViewData["Title"]</span>
</div>
</aside>
<!-- Topbar -->
<header class="navbar navbar-expand-md navbar-light d-print-none">
<div class="container-xl">
<div class="navbar-nav flex-row flex-fill justify-content-between align-items-center">
<span class="fw-medium">@ViewData["Title"]</span>
<a href="/Account/Logout" class="btn btn-sm btn-outline-danger">
<i class="ti ti-logout me-1"></i> 로그아웃
</a>
</div>
<div class="d-flex align-items-center">
<button type="button" class="btn-douzone-action btn-douzone-search" id="btnDouzoneSearch"><span class="hotkey-badge">F3</span>조회</button>
<button type="button" class="btn-douzone-action btn-douzone-save" id="btnDouzoneSave"><span class="hotkey-badge">F4</span>저장</button>
<button type="button" class="btn-douzone-action btn-douzone-delete" id="btnDouzoneDelete"><span class="hotkey-badge">F5</span>삭제</button>
<button type="button" class="btn-douzone-action btn-douzone-excel" id="btnDouzoneExcel"><span class="hotkey-badge">F7</span>엑셀</button>
<a href="/Account/Logout" class="btn-douzone-action style-danger ms-3 text-decoration-none" style="background-color: var(--status-error); color: white;">로그아웃</a>
</div>
</header>
<div class="page-wrapper">
<!-- Center content -->
<div class="page-body">
<div class="container-xl">
@RenderBody()
</div>
</div>
<!-- Footer -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-lg-auto ms-lg-auto">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
<a href="/Admin/Dashboard" class="link-secondary">대시보드</a>
</li>
<li class="list-inline-item">
<a href="/Admin/Operations" class="link-secondary">운영 관리</a>
</li>
</ul>
</div>
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
&copy; @DateTime.UtcNow.Year QuantEngine
</li>
</ul>
</div>
</div>
</div>
</footer>
<!-- Navigation Sub-Header Bar -->
<div style="background-color: var(--douzone-slate); padding: 4px 16px; border-bottom: 1px solid #1A252F;">
<ul class="nav nav-pills gap-1">
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Dashboard")" href="/Admin/Dashboard">대시보드</a></li>
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Collection")" href="/Admin/Collection">데이터 수집</a></li>
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Monitoring")" href="/Admin/Monitoring">모니터링</a></li>
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Decisions")" href="/Admin/Decisions">의사결정 이력</a></li>
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Database")" href="/Admin/Database">DB 테이블 관리</a></li>
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Users")" href="/Admin/Users">사용자 관리</a></li>
<li class="nav-item"><a class="nav-link text-white py-1 px-3 @NavActive("/Admin/Operations")" href="/Admin/Operations">운영 관리</a></li>
</ul>
</div>
<!-- 2. Center High-Density Data Grid Body -->
<main class="douzone-grid-body">
@RenderBody()
</main>
<!-- 3. Bottom Hotkey Guidance & Summary Footer Bar -->
<footer class="douzone-summary-footer">
<div>
<span><span class="hotkey-badge">Enter</span>다음 필드 이동</span>
<span class="ms-3"><span class="hotkey-badge">F2</span>코드 팝업</span>
<span class="ms-3"><span class="hotkey-badge">F3</span>조회</span>
<span class="ms-3"><span class="hotkey-badge">F4</span>저장</span>
<span class="ms-3"><span class="hotkey-badge">F5</span>삭제</span>
<span class="ms-3"><span class="hotkey-badge">F7</span>엑셀</span>
</div>
<div>
<span class="opacity-75">Douzone ERP Accounting UX Standard | QuantEngine v1.0</span>
</div>
</footer>
</div>
<!-- Tabler JS (bundles Bootstrap JS, incl. the Collapse plugin used by the mobile sidebar toggle above) -->
<script src="https://cdn.jsdelivr.net/npm/@@tabler/core@1.0.0/dist/js/tabler.min.js"></script>
<!-- Douzone Keyboard Engine Script -->
<script src="~/js/douzone-keyboard.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>