feat(wbs-ux): finalize SystemSettingsView with View/Edit mode toggle, Audit Trail timeline, and Export format selector
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 7s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 14s
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 8s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 8s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 8s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped

This commit is contained in:
2026-07-25 20:24:26 +09:00
parent cfcb1f9860
commit f72a33db74
+162 -15
View File
@@ -13,6 +13,12 @@ import QuantStatusChip from '../components/QuantStatusChip.vue'
import QuantDataGrid from '../components/QuantDataGrid.vue'
import type { ColDef } from 'ag-grid-community'
interface AuditLog {
timestamp: string;
user: string;
change: string;
}
interface SettingItem {
id: number;
setting_key: string;
@@ -22,19 +28,21 @@ interface SettingItem {
updated_at: string;
note: string;
attachments?: string[];
audit_history?: AuditLog[];
}
// 1. 검색, 필터, 탭 상태
const searchKeyword = ref('');
const activeTab = ref('ALL');
// 2. Master-Detail 바인딩 상태
// 2. Master-Detail 바인딩 및 편집 모드 토글 상태
const items = ref<SettingItem[]>([]);
const selectedItem = ref<SettingItem | null>(null);
const isEditMode = ref(false); // Readonly vs Edit Mode Toggle
// 3. 페이지네이션 상태
// 3. 페이지네이션 및 엑셀 드롭다운 상태
const currentPage = ref(1);
const pageSize = ref(10);
const showExportMenu = ref(false);
// 4. 토스트 알림 시스템 상태
const toastMessage = ref<{ text: string; type: 'success' | 'warning' | 'error' } | null>(null);
@@ -88,10 +96,25 @@ const columnDefs = ref<ColDef[]>([
const loadData = async () => {
items.value = [
{ id: 1, setting_key: 'D2_CASH_TARGET', category: 'BUDGET', setting_value: '500,000,000', status: 'ACTIVE', updated_at: '2026-07-25 14:00', note: 'D+2 즉시방어 현금 목표', attachments: ['budget_spec_v1.pdf'] },
{ id: 2, setting_key: 'RSI_UPPER_LIMIT', category: 'FACTOR', setting_value: '70.0', status: 'ACTIVE', updated_at: '2026-07-24 18:30', note: '과매수 상한 임계값', attachments: [] },
{ id: 3, setting_key: 'ANTI_LATE_ENTRY', category: 'RISK', setting_value: 'ENABLED', status: 'WARNING', updated_at: '2026-07-22 10:15', note: '추격매수 방지 가드 레벨 2', attachments: ['gate_audit.log'] },
{ id: 4, setting_key: 'WATERFALL_SELL_PRIORITY', category: 'EXECUTION', setting_value: 'STRICT', status: 'BLOCKED', updated_at: '2026-07-20 09:00', note: '단일 sell priority waterfall 룰', attachments: [] }
{
id: 1, setting_key: 'D2_CASH_TARGET', category: 'BUDGET', setting_value: '500,000,000', status: 'ACTIVE', updated_at: '2026-07-25 14:00', note: 'D+2 즉시방어 현금 목표', attachments: ['budget_spec_v1.pdf'],
audit_history: [
{ timestamp: '2026-07-25 14:00', user: 'admin_kjh', change: '설정값 4.8억 -> 5.0억 변경' },
{ timestamp: '2026-07-20 09:00', user: 'system', change: '신규 등록' }
]
},
{
id: 2, setting_key: 'RSI_UPPER_LIMIT', category: 'FACTOR', setting_value: '70.0', status: 'ACTIVE', updated_at: '2026-07-24 18:30', note: '과매수 상한 임계값', attachments: [],
audit_history: [{ timestamp: '2026-07-24 18:30', user: 'quant_dev', change: '상한 75 -> 70 보정' }]
},
{
id: 3, setting_key: 'ANTI_LATE_ENTRY', category: 'RISK', setting_value: 'ENABLED', status: 'WARNING', updated_at: '2026-07-22 10:15', note: '추격매수 방지 가드 레벨 2', attachments: ['gate_audit.log'],
audit_history: [{ timestamp: '2026-07-22 10:15', user: 'risk_officer', change: '경고 플래그 활성화' }]
},
{
id: 4, setting_key: 'WATERFALL_SELL_PRIORITY', category: 'EXECUTION', setting_value: 'STRICT', status: 'BLOCKED', updated_at: '2026-07-20 09:00', note: '단일 sell priority waterfall 룰', attachments: [],
audit_history: []
}
];
if (items.value.length > 0) {
selectedItem.value = JSON.parse(JSON.stringify(items.value[0]));
@@ -101,6 +124,7 @@ const loadData = async () => {
const handleRowSelect = (rows: any[]) => {
if (rows.length > 0) {
selectedItem.value = JSON.parse(JSON.stringify(rows[0]));
isEditMode.value = false; // 선택 시 읽기 모드로 재설정
}
};
@@ -132,7 +156,15 @@ const saveDetailForm = () => {
if (!selectedItem.value) return;
const idx = items.value.findIndex(i => i.id === selectedItem.value?.id);
if (idx !== -1) {
// 변경 이력 기록 추가
if (!selectedItem.value.audit_history) selectedItem.value.audit_history = [];
selectedItem.value.audit_history.unshift({
timestamp: new Date().toISOString().slice(0, 16).replace('T', ' '),
user: 'admin_kjh',
change: '설정값 및 노티 정보 수정 커밋'
});
items.value[idx] = JSON.parse(JSON.stringify(selectedItem.value));
isEditMode.value = false;
showToast('PostgreSQL 원장 설정 변경사항이 저장되었습니다.', 'success');
}
};
@@ -146,6 +178,11 @@ const deleteSelectedItem = () => {
}
};
const triggerExport = (fmt: string) => {
showExportMenu.value = false;
showToast(`${fmt} 형식으로 대용량 스트리밍 내보내기를 시작합니다.`, 'success');
};
onMounted(loadData);
</script>
@@ -164,8 +201,8 @@ onMounted(loadData);
<!-- Top Action & Search Bar -->
<div class="crud-toolbar">
<div class="toolbar-title">
<h3 class="title-text"> SCR-07: 올인원 고도화 CRUD 매니저</h3>
<span class="subtitle-text">Master-Detail, AG Grid, Toast, Tabs, Dropzone, Modal, 더존 6 표준 컴포넌트</span>
<h3 class="title-text"> SCR-07: 올인원 궁극의 CRUD 매니저</h3>
<span class="subtitle-text">Master-Detail, AG Grid, Toast, Tabs, Audit Timeline, Mode Switch, Modal, 더존 6 표준 컴포넌트</span>
</div>
<div class="toolbar-actions">
@@ -175,7 +212,17 @@ onMounted(loadData);
<button class="btn-action btn-search" @click="loadData"><span class="badge-key">F3</span>조회</button>
<button class="btn-action btn-create" @click="openCreateModal"><span class="badge-key">F4</span>신규 등록</button>
<button class="btn-action btn-delete" @click="deleteSelectedItem"><span class="badge-key">F5</span>삭제</button>
<button class="btn-action btn-excel"><span class="badge-key">F7</span>엑셀 다운로드</button>
<!-- 엑셀 내보내기 팝업 드롭다운 -->
<div class="export-dropdown-wrapper">
<button class="btn-action btn-excel" @click="showExportMenu = !showExportMenu">
<span class="badge-key">F7</span>엑셀 내보내기
</button>
<div v-if="showExportMenu" class="export-menu-popover">
<button @click="triggerExport('OpenXML Excel (.xlsx)')">📊 Excel (.xlsx) 내보내기</button>
<button @click="triggerExport('CSV (UTF-8)')">📄 CSV (UTF-8) 내보내기</button>
</div>
</div>
</div>
</div>
@@ -225,18 +272,26 @@ onMounted(loadData);
<div class="detail-form-panel" v-if="selectedItem">
<div class="panel-header">
<span class="panel-tag tag-active">Detail Form</span>
<h4 class="panel-title"> 상세 편집 [{{ selectedItem.setting_key }}]</h4>
<h4 class="panel-title"> 상세 데이터 [{{ selectedItem.setting_key }}]</h4>
<!-- Mode Switch Toggle -->
<div class="mode-switch-wrapper">
<button class="mode-btn" :class="{ active: !isEditMode }" @click="isEditMode = false">👁 읽기 모드</button>
<button class="mode-btn" :class="{ active: isEditMode }" @click="isEditMode = true"> 편집 모드</button>
</div>
</div>
<div class="panel-body form-scroll-body">
<div class="form-row">
<QuantLabel text="설정 키 (Key)" required />
<QuantInput v-model="selectedItem.setting_key" placeholder="키 명칭" />
<QuantInput v-model="selectedItem.setting_key" :disabled="!isEditMode" placeholder="키 명칭" />
</div>
<div class="form-row">
<QuantLabel text="카테고리" required />
<QuantComboBox
v-model="selectedItem.category"
:disabled="!isEditMode"
:options="[
{ label: 'BUDGET (예산)', value: 'BUDGET' },
{ label: 'FACTOR (팩터)', value: 'FACTOR' },
@@ -248,7 +303,7 @@ onMounted(loadData);
<div class="form-row">
<QuantLabel text="설정 값 (Value)" required />
<QuantInput v-model="selectedItem.setting_value" placeholder="설정값" />
<QuantInput v-model="selectedItem.setting_value" :disabled="!isEditMode" placeholder="설정값" />
</div>
<div class="form-row">
@@ -276,10 +331,22 @@ onMounted(loadData);
<div class="form-row">
<QuantLabel text="상세 비고 설명" />
<QuantTextArea v-model="selectedItem.note" :rows="3" />
<QuantTextArea v-model="selectedItem.note" :disabled="!isEditMode" :rows="2" />
</div>
<button class="btn-save-form" @click="saveDetailForm">
<!-- 변경 감사 이력 타임라인 (Audit History Timeline) -->
<div class="audit-timeline-section" v-if="selectedItem.audit_history && selectedItem.audit_history.length > 0">
<h5 class="timeline-title">🕒 변경 감사 이력 타임라인 (Audit Trail)</h5>
<div class="timeline-list">
<div v-for="(log, idx) in selectedItem.audit_history" :key="idx" class="timeline-item">
<span class="time-stamp">{{ log.timestamp }}</span>
<span class="user-id">[{{ log.user }}]</span>
<span class="change-desc">{{ log.change }}</span>
</div>
</div>
</div>
<button v-if="isEditMode" class="btn-save-form" @click="saveDetailForm">
💾 PostgreSQL 설정 변경사항 저장
</button>
</div>
@@ -397,6 +464,35 @@ onMounted(loadData);
.btn-delete { background-color: #DC2626; }
.btn-excel { background-color: #D97706; }
.export-dropdown-wrapper { position: relative; }
.export-menu-popover {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: white;
border: 1px solid #CBD5E1;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
z-index: 100;
width: 180px;
}
.export-menu-popover button {
padding: 8px 12px;
background: none;
border: none;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
color: #334155;
cursor: pointer;
}
.export-menu-popover button:hover { background-color: #F1F5F9; color: #2563EB; }
.badge-key {
background: rgba(255,255,255,0.2);
padding: 1px 4px;
@@ -476,6 +572,32 @@ onMounted(loadData);
gap: 8px;
}
.mode-switch-wrapper {
margin-left: auto;
display: flex;
gap: 4px;
background: #E2E8F0;
padding: 2px;
border-radius: 4px;
}
.mode-btn {
font-size: 0.7rem;
font-weight: 700;
border: none;
background: none;
padding: 2px 8px;
border-radius: 3px;
color: #64748B;
cursor: pointer;
}
.mode-btn.active {
background: white;
color: #1E293B;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.panel-tag {
font-size: 0.65rem;
font-weight: 700;
@@ -531,6 +653,31 @@ onMounted(loadData);
margin-top: 4px;
}
.audit-timeline-section {
border-top: 1px solid #E2E8F0;
padding-top: 10px;
margin-top: 6px;
}
.timeline-title {
font-size: 0.75rem;
font-weight: 700;
color: #475569;
margin: 0 0 6px 0;
}
.timeline-list { display: flex; flex-direction: column; gap: 4px; }
.timeline-item {
font-size: 0.7rem;
color: #64748B;
display: flex;
gap: 6px;
}
.time-stamp { font-family: monospace; font-weight: 700; color: #2563EB; }
.user-id { font-weight: 700; color: #334155; }
.change-desc { color: #475569; }
.btn-save-form {
margin-top: 12px;
padding: 10px 0;