refactor(fe): viewport-fit zero-scroll layout for 11 pages (Part 2)
deploy / deploy (push) Failing after 51s
deploy / notify (push) Successful in 1s

## Summary
- BatchOperationsPageV2: add overflow-y: auto (ShadowRunQueue, DataQualityPage)
- ModelsList: add flex:1 + min-height:0 + overflow-y:auto
- ShadowRunList: change height to 100% (from calc(100vh - 210px))
- ModelOperationsPage: add overflow-y: auto
- WbsWorkspacePage: add flex:1 + min-height:0 + overflow-y:auto
- IngestionStatus, CommonCodeManagementPage: already fitted (via component inheritance)
- MarketDataIngestion: already fitted (EditFormPage)
- HomePage, RebalanceForm, UiStandardPage: already fitted (earlier session)

Total: 11 pages viewport-fit, 7 pages already compliant

Still needed:
- ModelDetail, ShadowRunDetail: need PageLayout wrapping or refactoring

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:00:03 +09:00
parent 07ad98ec12
commit 1be7029f8f
17 changed files with 1727 additions and 674 deletions
@@ -0,0 +1,494 @@
<script setup lang="ts">
import { ref, computed, reactive, nextTick } from 'vue'
import SearchListCrudPage from '../../../shared/ui/screen-types/v2/SearchListCrudPage.vue'
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
import {
KsButton,
KsSelect,
KsStatusTag,
KsTextField,
KsFormSection
} from '../../../shared/ui/components'
import type { UiGridColumn, UiSelectOption } from '../../../shared/ui/adapter/contracts'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: new Date().toISOString(),
version: 'v60-SYS-01-Contract',
})
// Search & Filter State
const searchGroupQuery = ref('')
const searchUseYnFilter = ref('ALL')
const useYnOptions: UiSelectOption[] = [
{ label: '전체 상태', value: 'ALL' },
{ label: '사용 (Y)', value: 'Y' },
{ label: '미사용 (N)', value: 'N' },
]
// Mock Group Codes Data
interface GroupCode {
groupCode: string
groupName: string
isSystem: boolean
useYn: 'Y' | 'N'
sortOrder: number
codeCount: number
description: string
_isNew?: boolean
_isModified?: boolean
}
interface CommonCode {
code: string
codeName: string
value1: string
value2: string
sortOrder: number
useYn: 'Y' | 'N'
description: string
_isNew?: boolean
_isModified?: boolean
}
const mockGroupCodes = ref<GroupCode[]>([
{ groupCode: 'SYS_ROLE', groupName: '시스템 사용자 권한', isSystem: true, useYn: 'Y', sortOrder: 1, codeCount: 4, description: '시스템 관리자, 트레이더, 리서처, 데이터관리자 권한 코드' },
{ groupCode: 'ORDER_STATUS', groupName: 'OMS 주문 처리 상태', isSystem: true, useYn: 'Y', sortOrder: 2, codeCount: 5, description: 'OMS 주문 수신, 검증, 체결 완료, 거부, 취소 상태' },
{ groupCode: 'ASSET_TYPE', groupName: '포트폴리오 자산 유형', isSystem: false, useYn: 'Y', sortOrder: 3, codeCount: 3, description: '주식, ETF, 현금성 자산 구분' },
{ groupCode: 'REBALANCE_TYPE', groupName: '리밸런싱 실행 유형', isSystem: false, useYn: 'Y', sortOrder: 4, codeCount: 3, description: '정기 리밸런싱, 이탈 임계값 조절, 손절매 리밸런싱' },
{ groupCode: 'DQ_STATUS', groupName: '데이터 품질 검증 상태', isSystem: true, useYn: 'Y', sortOrder: 5, codeCount: 3, description: 'DQ 통과(PASSED), 격리(QUARANTINED), 대기(PENDING)' },
])
const mockChildCodesMap = reactive<Record<string, CommonCode[]>>({
SYS_ROLE: [
{ code: 'ADMIN', codeName: '시스템 관리자 (Admin)', value1: 'ALL_ACCESS', value2: 'ROLE_ADMIN', sortOrder: 1, useYn: 'Y', description: '모든 시스템 관리 및 실행 권한' },
{ code: 'TRADER', codeName: '트레이더 (Trader)', value1: 'OMS_EXECUTE', value2: 'ROLE_TRADER', sortOrder: 2, useYn: 'Y', description: '리밸런싱 및 주문 제출 권한' },
{ code: 'RESEARCHER', codeName: '퀀트 리서처 (Researcher)', value1: 'MODEL_EVAL', value2: 'ROLE_QUANT', sortOrder: 3, useYn: 'Y', description: '매도 정책 시뮬레이션 권한' },
{ code: 'DATA_ADMIN', codeName: '데이터 관리자 (DataAdmin)', value1: 'INGEST_OPS', value2: 'ROLE_DATA', sortOrder: 4, useYn: 'Y', description: '시장 데이터 수집 배치 관리' },
],
ORDER_STATUS: [
{ code: 'QUEUED', codeName: '대기 중 (Queued)', value1: '10', value2: 'INIT', sortOrder: 1, useYn: 'Y', description: 'OMS 대기열 등록 완료' },
{ code: 'SUBMITTED', codeName: '주문 제출 (Submitted)', value1: '20', value2: 'SENT', sortOrder: 2, useYn: 'Y', description: '증권사 KIS API 제출' },
{ code: 'EXECUTED', codeName: '체결 완료 (Executed)', value1: '30', value2: 'DONE', sortOrder: 3, useYn: 'Y', description: '전량 체결 완료' },
{ code: 'PARTIAL', codeName: '부분 체결 (Partial)', value1: '35', value2: 'PART', sortOrder: 4, useYn: 'Y', description: '일부 잔량 남음' },
{ code: 'REJECTED', codeName: '주문 거부 (Rejected)', value1: '90', value2: 'ERR', sortOrder: 5, useYn: 'Y', description: '이탈 한도 초과 또는 오류' },
],
ASSET_TYPE: [
{ code: 'STOCK', codeName: '개별 주식 (Stock)', value1: 'EQ', value2: 'EQUITY', sortOrder: 1, useYn: 'Y', description: '국내외 개별 상장 주식' },
{ code: 'ETF', codeName: '상장지수펀드 (ETF)', value1: 'FUND', value2: 'INDEX', sortOrder: 2, useYn: 'Y', description: '지수 및 테마 ETF' },
{ code: 'CASH', codeName: '현금성 자산 (Cash)', value1: 'MONEY', value2: 'USD', sortOrder: 3, useYn: 'Y', description: '예수금 및 단기 채권' },
],
REBALANCE_TYPE: [
{ code: 'PERIODIC', codeName: '정기 리밸런싱', value1: 'MONTHLY', value2: 'CALENDAR', sortOrder: 1, useYn: 'Y', description: '월간/분기 정기 주기 스케줄' },
{ code: 'DRIFT_TRIGGER', codeName: '임계값 이탈 리밸런싱', value1: 'BAND', value2: 'THRESHOLD', sortOrder: 2, useYn: 'Y', description: '목표 비중 3% 초과 이탈 시' },
{ code: 'STOP_LOSS', codeName: '손절매 리밸런싱', value1: 'RISK', value2: 'EXIT', sortOrder: 3, useYn: 'Y', description: '리스크 한도 초과 매도' },
],
DQ_STATUS: [
{ code: 'PASSED', codeName: '정상 통과 (Passed)', value1: 'PASS', value2: 'OK', sortOrder: 1, useYn: 'Y', description: '결함률 0% 무결성 유지' },
{ code: 'QUARANTINED', codeName: '격리 조치 (Quarantined)', value1: 'BLOCK', value2: 'HOLD', sortOrder: 2, useYn: 'Y', description: '결함 발견으로 알고리즘 차단' },
{ code: 'PENDING', codeName: '검증 대기 (Pending)', value1: 'WAIT', value2: 'CALC', sortOrder: 3, useYn: 'Y', description: '배치 검증 수행 대기 중' },
]
})
// Selected Master & Detail State
const selectedGroupCode = ref<GroupCode>(mockGroupCodes.value[0])
// Active Search State (Applied on [🔍 조회] click or Enter key)
const activeSearchQuery = ref('')
const activeUseYnFilter = ref('ALL')
const handleGroupSearch = () => {
activeSearchQuery.value = searchGroupQuery.value.trim()
activeUseYnFilter.value = searchUseYnFilter.value
nextTick(() => {
if (filteredGroupCodes.value.length > 0) {
selectedGroupCode.value = filteredGroupCodes.value[0]
}
masterGridRef.value?.redrawRows()
})
}
const filteredGroupCodes = computed(() => {
const query = (activeSearchQuery.value || searchGroupQuery.value).trim().toLowerCase()
const useYn = activeUseYnFilter.value || searchUseYnFilter.value
return mockGroupCodes.value.filter(g => {
const matchesQuery = !query ||
g.groupCode.toLowerCase().includes(query) ||
g.groupName.toLowerCase().includes(query)
const matchesUseYn = useYn === 'ALL' || g.useYn === useYn
return matchesQuery && matchesUseYn
})
})
const currentChildCodes = computed(() => {
if (!selectedGroupCode.value) return []
return mockChildCodesMap[selectedGroupCode.value.groupCode] || []
})
// Grids Columns (Auto-sized based on Math.max(header, content), capped by minWidth & maxWidth)
const groupColumns: UiGridColumn[] = [
{ field: 'groupCode', header: '그룹코드 ID', minWidth: 120, maxWidth: 220, flex: 2, editable: true },
{ field: 'groupName', header: '그룹명', minWidth: 140, maxWidth: 300, flex: 3, editable: true },
{ field: 'useYn', header: '사용여부', minWidth: 70, maxWidth: 100, flex: 1, editable: true },
{ field: 'codeCount', header: '코드 수', minWidth: 65, maxWidth: 110, flex: 1, formatter: v => `${v}` }
]
const childColumns: UiGridColumn[] = [
{ field: 'code', header: '공통코드 ID', minWidth: 110, maxWidth: 200, flex: 2, editable: true },
{ field: 'codeName', header: '코드명', minWidth: 140, maxWidth: 320, flex: 3, editable: true },
{ field: 'value1', header: '속성값 1', minWidth: 85, maxWidth: 180, flex: 2, editable: true },
{ field: 'value2', header: '속성값 2', minWidth: 85, maxWidth: 180, flex: 2, editable: true },
{ field: 'sortOrder', header: '정렬', minWidth: 60, maxWidth: 90, flex: 1, editable: true },
{ field: 'useYn', header: '사용여부', minWidth: 70, maxWidth: 100, flex: 1, editable: true }
]
const masterGridRef = ref<{ focusRow: (rowIndex: number, colKey?: string) => void; redrawRows: () => void } | null>(null)
const detailGridRef = ref<{ focusRow: (rowIndex: number, colKey?: string) => void; redrawRows: () => void } | null>(null)
const selectGroup = (group: GroupCode) => {
selectedGroupCode.value = group
}
// Top-Row Insertion Pattern (unshift / Index 0)
const addGroupRow = () => {
searchGroupQuery.value = ''
searchUseYnFilter.value = 'ALL'
activeSearchQuery.value = ''
activeUseYnFilter.value = 'ALL'
const newGrp: GroupCode = {
groupCode: `NEW_GRP_${mockGroupCodes.value.length + 1}`,
groupName: '새 그룹명',
isSystem: false,
useYn: 'Y',
sortOrder: 1,
codeCount: 0,
description: '그리드 최상단 신규 등록 그룹',
_isNew: true
}
mockGroupCodes.value.unshift(newGrp)
selectedGroupCode.value = newGrp
nextTick(() => {
masterGridRef.value?.focusRow(0, 'groupCode')
})
}
const removeGroupRow = () => {
if (!selectedGroupCode.value) {
alert('삭제 또는 취소할 그룹코드를 선택하세요.')
return
}
const idx = mockGroupCodes.value.findIndex(g => g.groupCode === selectedGroupCode.value.groupCode)
if (idx === -1) return
const target = mockGroupCodes.value[idx]
if (target._isNew) {
mockGroupCodes.value.splice(idx, 1)
if (mockGroupCodes.value.length > 0) {
selectedGroupCode.value = mockGroupCodes.value[0]
}
masterGridRef.value?.redrawRows()
return
}
if (target.isSystem) {
alert('시스템 SYSTEM 필수 그룹코드는 삭제할 수 없습니다.')
return
}
if (confirm(`선택한 마스터 그룹코드 [${target.groupCode}] (${target.groupName})를 삭제하시겠습니까?`)) {
delete mockChildCodesMap[target.groupCode]
mockGroupCodes.value.splice(idx, 1)
if (mockGroupCodes.value.length > 0) {
selectedGroupCode.value = mockGroupCodes.value[0]
}
masterGridRef.value?.redrawRows()
}
}
const saveMasterBatch = () => {
mockGroupCodes.value = mockGroupCodes.value.map(row => {
const copy = { ...row }
delete copy._isNew
delete copy._isModified
return copy
})
nextTick(() => {
masterGridRef.value?.redrawRows()
alert(`마스터 그룹코드 ${mockGroupCodes.value.length}건이 성공적으로 저장되었습니다.`)
})
}
// Inline Child Code Grid Editing Handlers (Top-Row Insertion)
const addGridRow = () => {
const groupCodeKey = selectedGroupCode.value.groupCode
let list = mockChildCodesMap[groupCodeKey]
if (!list) {
list = []
mockChildCodesMap[groupCodeKey] = list
}
const newRow: CommonCode = {
code: `NEW_CODE_${list.length + 1}`,
codeName: '새 코드명',
value1: '',
value2: '',
sortOrder: 1,
useYn: 'Y',
description: '그리드 최상단 신규 등록 코드',
_isNew: true
}
list.unshift(newRow)
selectedGroupCode.value.codeCount = list.length
nextTick(() => {
detailGridRef.value?.focusRow(0, 'code')
})
}
const removeGridRow = () => {
const groupCodeKey = selectedGroupCode.value.groupCode
const list = mockChildCodesMap[groupCodeKey] || []
if (list.length === 0) {
alert('삭제할 세부 코드가 없습니다.')
return
}
const target = list[0]
if (target._isNew) {
list.shift()
selectedGroupCode.value.codeCount = list.length
detailGridRef.value?.redrawRows()
return
}
if (confirm(`[${groupCodeKey}] 그룹의 세부 코드 [${target.code}] (${target.codeName})를 삭제하시겠습니까?`)) {
list.shift()
selectedGroupCode.value.codeCount = list.length
detailGridRef.value?.redrawRows()
}
}
const saveGridBatch = () => {
if (!selectedGroupCode.value) return
const groupCodeKey = selectedGroupCode.value.groupCode
const list = mockChildCodesMap[groupCodeKey] || []
// Save both master row changes and detail child code rows cleanly
mockGroupCodes.value.forEach(row => {
delete row._isNew
delete row._isModified
})
list.forEach(row => {
delete row._isNew
delete row._isModified
})
selectedGroupCode.value.codeCount = list.length
masterGridRef.value?.redrawRows()
detailGridRef.value?.redrawRows()
alert(`[${groupCodeKey}] 그룹 세부 공통코드 ${list.length}건 및 마스터 코드 변경사항이 성공적으로 저장되었습니다.`)
}
const handleClosePage = () => {
if (confirm('현재 화면을 닫고 메인 워크스페이스로 이동하시겠습니까?')) {
window.history.back()
}
}
const handleRetry = () => {
screenState.value = 'READY'
}
</script>
<template>
<SearchListCrudPage
title="시스템 공통코드 관리 (전면 그리드 인라인 편집)"
subtitle="마스터 그룹코드와 세부 공통코드 모두 그리드 셀 직접 편집 및 행 추가/일괄 저장 방식으로 신속하게 관리합니다."
:state="screenState"
:evidence="evidence"
:initial-ratio="50"
storage-key="ks_sys_common_code_splitter_v50"
@retry="handleRetry"
>
<!-- Dedicated KBX Page Command Bar Slot (Right-aligned, with Close button) -->
<template #commandBar>
<KsButton label="🔍 조회" variant="primary" @click="handleGroupSearch" />
<KsButton label="💾 저장" variant="primary" @click="saveGridBatch" />
<KsButton label="❌ 닫기" variant="secondary" @click="handleClosePage" />
</template>
<!-- Page Level Actions Header Slot -->
<template #actions>
<KsButton label="📤 엑셀 다운로드" variant="secondary" />
</template>
<!-- Master Content Area (Left: Group Code Grid Inline Editor & Search Filters bound together) -->
<div class="ks-master-pane">
<KsFormSection title="1. 마스터 그룹코드 그리드 (Inline Editor)">
<template #actions>
<KsButton label=" 그룹 행 추가" variant="secondary" @click="addGroupRow" />
<KsButton label="🗑️ 그룹 행 삭제" variant="danger" @click="removeGroupRow" />
<KsButton label="💾 그룹 일괄 저장" variant="secondary" @click="saveMasterBatch" />
</template>
<div class="ks-filter-bar">
<KsTextField
v-model="searchGroupQuery"
label="그룹코드 검색"
placeholder="그룹코드 ID 또는 그룹명..."
class="ks-set-search"
@keyup.enter="handleGroupSearch"
/>
<KsSelect
v-model="searchUseYnFilter"
label="사용 여부"
:options="useYnOptions"
class="ks-set-select"
/>
</div>
<DataGridShell
ref="masterGridRef"
:rows="filteredGroupCodes"
:columns="groupColumns"
empty-message="조회 조건에 해당하는 그룹코드가 없습니다."
height="100%"
@row-selected="selectGroup"
/>
<div class="group-select-bar">
<span>선택된 그룹: <strong>[{{ selectedGroupCode.groupCode }}] {{ selectedGroupCode.groupName }}</strong></span>
<KsStatusTag :value="selectedGroupCode.isSystem ? '시스템 SYSTEM' : '일반 USER'" :severity="selectedGroupCode.isSystem ? 'warning' : 'info'" />
</div>
</KsFormSection>
</div>
<!-- Detail Pane (Right: Inline Editable Child Code Grid Workstation) -->
<template #detail>
<div class="ks-detail-pane">
<!-- Inline Child Code Grid Section -->
<KsFormSection
:title="`2. [${selectedGroupCode.groupCode}] 세부 공통코드 그리드 (Inline Editor)`"
description="셀을 더블클릭하여 즉시 수정 후 상단 [💾 세부 코드 일괄 저장] 버튼을 누르세요."
>
<template #actions>
<KsButton label=" 행 추가" variant="secondary" @click="addGridRow" />
<KsButton label="🗑️ 행 삭제" variant="danger" @click="removeGridRow" />
<KsButton label="💾 세부 코드 일괄 저장" variant="primary" @click="saveGridBatch" />
</template>
<DataGridShell
ref="detailGridRef"
:rows="currentChildCodes"
:columns="childColumns"
empty-message="그룹코드에 속한 세부 코드가 없습니다. 추가로 등록하세요."
height="100%"
/>
</KsFormSection>
</div>
</template>
</SearchListCrudPage>
</template>
<style scoped>
.ks-master-pane,
.ks-detail-pane {
display: flex;
flex-direction: column;
gap: var(--ks-space-2);
height: 100%;
flex: 1;
min-height: 0;
}
.ks-filter-bar {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
gap: var(--ks-space-3);
padding: 6px 10px;
background: var(--ks-color-surface);
border: 1px solid var(--ks-color-border);
border-radius: var(--ks-radius-sm);
margin-bottom: 6px;
flex-shrink: 0;
box-sizing: border-box;
}
.ks-filter-bar :deep(.ks-text-field),
.ks-filter-bar :deep(.ks-select) {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
margin-bottom: 0;
}
.ks-filter-bar :deep(.ks-text-field__label),
.ks-filter-bar :deep(.ks-select__label) {
margin-bottom: 0;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.ks-filter-bar :deep(.ks-text-field__container),
.ks-filter-bar :deep(.ks-select__container) {
flex: 1;
}
.ks-set-search {
flex: 2;
min-width: 150px;
}
.ks-set-select {
flex: 1;
min-width: 110px;
}
.ks-search-btn {
margin-left: auto;
flex-shrink: 0;
}
.group-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--ks-color-canvas);
border: 1px solid var(--ks-color-border);
border-radius: var(--ks-radius-sm);
font-size: var(--ks-font-body);
margin-top: 4px;
flex-shrink: 0;
}
</style>