Files
KArtSell.Aegis/frontend/src/features/system/pages/CommonCodeManagementPage.vue
T
kjh2064 f3ddd4d84b
deploy / deploy (push) Failing after 48s
deploy / notify (push) Successful in 1s
fix(fe): KsDataGrid + CommonCodeManagementPage - grid edit issues
Fixed 3 critical bugs in grid editing:

1. onCellValueChanged redrawRows() removal
   - Removed event.api.redrawRows() that was resetting cell input
   - Issue: redrawRows() triggered computed property re-evaluation
   - Result: Array reference changed, grid lost input value

2. ScrollApiModule registration
   - Added ScrollApiModule to ModuleRegistry
   - Issue: focusRow() called ensureIndexVisible without module
   - Result: AG Grid #200 error, page hung

3. onCellEditingStopped removal
   - Removed auto-restart of edit mode on cell exit
   - Issue: Prevented navigateToNextCell from working on Enter key
   - Result: Enter key now properly moves focus to next cell

4. CommonCodeManagementPage focusRow safety
   - Wrapped focusRow() in try-catch
   - Issue: focusRow may not be available, causing errors
   - Result: Grid continues even if focusRow unavailable

Affects: /system/common-codes grid editing and all pages using KsDataGrid

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-16 20:42:18 +09:00

499 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
if (!mockChildCodesMap[groupCodeKey]) {
mockChildCodesMap[groupCodeKey] = reactive<CommonCode[]>([])
}
const list = mockChildCodesMap[groupCodeKey]
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(() => {
try {
detailGridRef.value?.focusRow?.(0, 'code')
} catch (e) {
console.warn('focusRow not available', e)
}
})
}
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>