import '@material/web/icon/icon.js'
import '@material/web/button/elevated-button.js'
import '@operato/data-grist/ox-grist.js'
import '@operato/data-grist/ox-filters-form.js'
import '@operato/data-grist/ox-record-creator.js'
import './kpi-viz-editor.js'
import './kpi-grade-editor.js'
import { CommonButtonStyles, CommonHeaderStyles, CommonGristStyles, ScrollbarStyles } from '@operato/styles'
import { PageView } from '@operato/shell'
import { css, html } from 'lit'
import { customElement, property, query, state } from 'lit/decorators.js'
import { ScopedElementsMixin } from '@open-wc/scoped-elements'
import { ColumnConfig, DataGrist, FetchOption } from '@operato/data-grist'
import { client } from '@operato/graphql'
import { i18next, localize } from '@operato/i18n'
import { notify, openPopup } from '@operato/layout'
import { OxPopup, OxPrompt } from '@operato/popup'
import { isMobileDevice } from '@operato/utils'
import { p13n } from '@operato/p13n'
import gql from 'graphql-tag'
/**
* KPI scoreType — value → score 변환 방식 (null이면 미설정)
*
* DIRECT — value = score, 변환 없음
* FORMULA — scoreFormula 수식으로 변환
* LOOKUP — grade table(1D)로 변환
* CUSTOM — 2D 룩업 등 특수 변환
*/
export const KPI_SCORE_TYPE = {
DIRECT: 'DIRECT',
FORMULA: 'FORMULA',
LOOKUP: 'LOOKUP',
CUSTOM: 'CUSTOM'
} as const
/**
* KPI valueType — value 획득 방식
*
* MEASURED — 외부 시스템 수집 측정값
* ASSESSED — 감리자 직접 평가 (1~5)
* CALCULATED — formula 자동 계산
* COMPOSITE — 다차원 복합 입력
*/
export const KPI_VALUE_TYPE = {
MEASURED: 'MEASURED',
ASSESSED: 'ASSESSED',
CALCULATED: 'CALCULATED',
COMPOSITE: 'COMPOSITE'
} as const
export function inferScoreType(kpi: any): string {
if (kpi.scoreType) return kpi.scoreType
if (kpi.scoreFormula) return KPI_SCORE_TYPE.FORMULA
if (kpi.grades && typeof kpi.grades === 'object' && !Array.isArray(kpi.grades)) return KPI_SCORE_TYPE.CUSTOM
if (Array.isArray(kpi.grades) && kpi.grades.length > 0) return KPI_SCORE_TYPE.LOOKUP
return KPI_SCORE_TYPE.DIRECT
}
import { KpiImporter } from './kpi-importer'
import { KpiGradeEditor } from './kpi-grade-editor'
import { KpiVizEditor } from './kpi-viz-editor'
@customElement('kpi-list-page')
export class KpiListPage extends p13n(localize(i18next)(ScopedElementsMixin(PageView))) { static styles = [
ScrollbarStyles,
CommonGristStyles,
CommonHeaderStyles,
css`
:host { display: flex;
width: 100%;
--grid-record-emphasized-background-color: #8b0000;
--grid-record-emphasized-color: #ff6b6b;
}
ox-grist { overflow-y: auto;
flex: 1;
}
ox-filters-form { flex: 1;
}
`
]
static get scopedElements() { return { 'kpi-importer': KpiImporter,
'kpi-grade-editor': KpiGradeEditor,
'kpi-viz-editor': KpiVizEditor
}
}
@property({ type: Object }) gristConfig: any
@property({ type: String }) mode: 'CARD' | 'GRID' | 'LIST' = isMobileDevice() ? 'CARD' : 'GRID'
@query('ox-grist') protected grist!: DataGrist
@state() availableVariables: any[] = []
@state() availableVariablesLoaded = false
@state() hierarchicalView = false
async getAvailableKpiMetricVariables(currentKpi?: any) { // Leaf KPI인 경우: kpi-metric을 변수로 사용
if (!currentKpi || currentKpi.isLeaf) { if (this.availableVariablesLoaded) { return this.availableVariables
}
const response = await client.query({ query: gql`
query { kpiMetrics { items { name
description
unit
}
}
}
`
})
this.availableVariables = (response.data.kpiMetrics.items || []).map(metric => ({ name: metric.name,
description: metric.description,
type: 'kpi-metric',
unit: metric.unit
}))
this.availableVariablesLoaded = true
return this.availableVariables
}
// 부모 KPI인 경우: 자식 KPI를 변수로 사용
try { const response = await client.query({ query: gql`
query ($id: String!) { kpi(id: $id) { children { id
name
description
}
}
}
`,
variables: { id: currentKpi.id
}
})
return (response.data.kpi.children || []).map(childKpi => ({ name: childKpi.name,
description: childKpi.description,
type: 'child-kpi'
}))
} catch (error) { console.error('Failed to fetch child KPIs:', error)
return []
}
}
get context() { return { title: i18next.t('title.kpi list'),
search: { handler: (search: string) => { this.grist.searchText = search
},
value: this.grist.searchText
},
filter: { handler: () => { this.grist.toggleHeadroom()
}
},
help: 'kpi/kpi',
actions: [
{ title: this.hierarchicalView ? 'List View' : 'Tree View',
action: this._toggleHierarchicalView.bind(this),
icon: this.hierarchicalView ? 'list' : 'account_tree'
},
{ title: i18next.t('button.save'),
action: this._updateKpi.bind(this),
...CommonButtonStyles.save
},
{ title: i18next.t('button.delete'),
action: this._deleteKpi.bind(this),
...CommonButtonStyles.delete
}
],
exportable: { name: i18next.t('title.kpi list'),
data: this.exportHandler.bind(this)
},
importable: { handler: this.importHandler.bind(this)
}
}
}
render() { const mode = this.mode || (isMobileDevice() ? 'CARD' : 'GRID')
return html`
`
}
connectedCallback() { super.connectedCallback()
this.fetchKpiMetrics()
}
async pageInitialized(lifecycle: any) { this.gristConfig = { list: { fields: ['name', 'description'],
details: [
'name',
'description',
'formula',
'active',
'state',
'vizType',
'schedule',
'scheduleId',
'timezone',
'version',
'createdAt',
'updatedAt',
'creator',
'updater',
'thumbnail'
]
},
columns: [
{ type: 'gutter', gutterName: 'sequence' },
{ type: 'gutter', gutterName: 'row-selector', multiple: true },
// KPI 실적값 계산 버튼 추가
{ type: 'gutter',
gutterName: 'button',
icon: 'calculate',
title: '실적값 계산',
handlers: { click: (columns, data, column, record, rowIndex) => { this._calculateKpiValue(record)
}
}
},
{ type: 'string',
name: 'name',
header: '이름',
record: { editable: true },
filter: 'search',
sortable: true,
width: 120
},
{ type: 'string',
name: 'description',
header: i18next.t('field.description'),
record: { editable: true },
filter: 'search',
width: 200
},
{ type: 'resource-object',
name: 'parent',
label: true,
header: '상위 KPI',
record: { editable: true,
options: { title: i18next.t('title.lookup KPI'),
queryName: 'kpis',
basicArgs: { filters: [{ name: 'isLeaf', operator: 'eq', value: false }]
}
}
},
width: 200
},
{ type: 'boolean',
name: 'isLeaf',
header: '리프 KPI',
record: { editable: true },
width: 100
},
{ type: 'formula',
name: 'formula',
header: '산식',
record: { editable: true,
availableVariables: async (value: string, column: ColumnConfig, record: any) => { // Leaf KPI인 경우: kpi-metric을 변수로 사용
if (!record || record.isLeaf) { return await this.getAvailableKpiMetricVariables(record)
}
// 부모 KPI인 경우: 자식 KPI를 변수로 사용
return (record.children || []).map(child => ({ name: child.name,
description: child.description || child.name,
type: 'child-kpi',
unit: ''
}))
}
},
width: 320
},
{ type: 'select',
name: 'periodType',
header: '계산주기',
record: { editable: true,
options: [
{ value: '', display: '' },
{ value: 'DAY', display: '일' },
{ value: 'WEEK', display: '주' },
{ value: 'MONTH', display: '월' },
{ value: 'QUARTER', display: '분기' },
{ value: 'YEAR', display: '년' },
{ value: 'RANGE', display: '범위' },
{ value: 'ALLTIME', display: '전체' }
]
},
width: 80
},
{ type: 'formula',
name: 'scoreFormula',
header: '성과점수수식',
record: { editable: true,
availableVariables: async (value: string, column: ColumnConfig, record: any) => { // leaf 이면 value를 사용
if (record.isLeaf) { return [
{ name: 'value',
description: 'KPI 실적값',
type: 'kpi-value',
unit: ''
}
]
}
// 부모 이면 자식 KPI들을 변수로 사용
return record.children.map(child => ({ name: child.name,
description: child.description || child.name,
type: 'kpi-score',
unit: ''
}))
},
includeDefaultFunctions: false,
availableFunctions: [
{ name: 'INTEGRATE()',
description: '수치 적분',
template: 'INTEGRATE({func}, {a}, {b}, {n})',
syntax: 'INTEGRATE(func, a, b, n)',
parameters: ['func - 적분할 함수', 'a - 하한값', 'b - 상한값', 'n - 분할 수 (기본값: 1000)'],
returnType: 'number',
help: '사다리꼴 적분법을 사용하여 수치 적분을 계산합니다.',
examples: ['INTEGRATE(x => x*x, 0, 1, 1000)', 'INTEGRATE([효율성], 0, 1)']
},
{ name: 'BETA_FUNCTION()',
description: '베타 함수',
template: 'BETA_FUNCTION({x}, {alpha}, {beta})',
syntax: 'BETA_FUNCTION(x, alpha, beta)',
parameters: ['x - 변수 (0 ≤ x ≤ 1)', 'alpha - 첫 번째 모수', 'beta - 두 번째 모수'],
returnType: 'number',
help: '베타 분포 함수를 계산합니다. t^(α-1) × (1-t)^(β-1)',
examples: ['BETA_FUNCTION(0.5, 2, 3)', 'BETA_FUNCTION([품질지수], 3, 2)']
},
{ name: 'INCOMPLETE_BETA()',
description: '불완전 베타 함수',
template: 'INCOMPLETE_BETA({x}, {alpha}, {beta})',
syntax: 'INCOMPLETE_BETA(x, alpha, beta)',
parameters: ['x - 상한값 (0 ≤ x ≤ 1)', 'alpha - 첫 번째 모수', 'beta - 두 번째 모수'],
returnType: 'number',
help: '불완전 베타 함수를 수치 적분으로 계산합니다.',
examples: ['INCOMPLETE_BETA(0.7, 2, 3)', 'INCOMPLETE_BETA([목표달성률]/100, 2, 3)']
},
{ name: 'COMPLETE_BETA()',
description: '완전 베타 함수',
template: 'COMPLETE_BETA({alpha}, {beta})',
syntax: 'COMPLETE_BETA(alpha, beta)',
parameters: ['alpha - 첫 번째 모수', 'beta - 두 번째 모수'],
returnType: 'number',
help: '완전 베타 함수 B(α,β)를 계산합니다.',
examples: ['COMPLETE_BETA(2, 3)', 'COMPLETE_BETA(3, 2)']
},
{ name: 'PERFORMANCE_INDEX()',
description: '성과 지수',
template: 'PERFORMANCE_INDEX({x}, {alpha1}, {beta1}, {alpha2}, {beta2})',
syntax: 'PERFORMANCE_INDEX(x, alpha1, beta1, alpha2, beta2)',
parameters: [
'x - 성과 값 (0 ≤ x ≤ 1)',
'alpha1, beta1 - 분자 베타 함수 모수',
'alpha2, beta2 - 분모 베타 함수 모수'
],
returnType: 'number',
help: '성과 지수를 계산합니다: 1 - (불완전 베타 / 완전 베타)',
examples: ['PERFORMANCE_INDEX(0.8, 2, 3, 2, 3)', 'PERFORMANCE_INDEX([성과점수]/100, 2, 3, 2, 3)']
},
{ name: 'EXP()',
description: '지수 함수',
template: 'EXP({x})',
syntax: 'EXP(x)',
parameters: ['x - 지수'],
returnType: 'number',
help: '자연상수 e의 x제곱을 계산합니다.',
examples: ['EXP(1)', 'EXP([효율성])']
},
{ name: 'LOG()',
description: '자연 로그',
template: 'LOG({x})',
syntax: 'LOG(x)',
parameters: ['x - 로그를 취할 값'],
returnType: 'number',
help: '자연 로그 ln(x)를 계산합니다.',
examples: ['LOG(2.718)', 'LOG([성과점수])']
},
{ name: 'POW()',
description: '거듭제곱',
template: 'POW({x}, {y})',
syntax: 'POW(x, y)',
parameters: ['x - 밑수', 'y - 지수'],
returnType: 'number',
help: 'x의 y제곱을 계산합니다.',
examples: ['POW(2, 3)', 'POW([효율성], 2)']
},
{ name: 'EXPONENTIAL_DECAY()',
description: '지수 감쇠',
template: 'EXPONENTIAL_DECAY({value}, {scale}, {power})',
syntax: 'EXPONENTIAL_DECAY(value, scale, power)',
parameters: ['value - 입력 값', 'scale - 스케일 파라미터', 'power - 지수 파라미터'],
returnType: 'number',
help: '지수 감쇠 함수 exp(-(value/scale)^power)를 계산합니다.',
examples: ['EXPONENTIAL_DECAY(50, 100, 2)', 'EXPONENTIAL_DECAY([목표달성률], 50, 2)']
}
]
},
width: 200
},
{ type: 'string',
name: 'grades',
header: '성과지수 Lookup',
record: { editable: false,
renderer: (v, c, r) => this.renderGradesCell(r)
},
width: 150
},
{
type: 'select',
name: 'scoreType',
header: 'Score 산정',
record: {
editable: true,
options: [
{ value: '', display: '(미설정)' },
{ value: 'DIRECT', display: 'DIRECT (변환없음)' },
{ value: 'FORMULA', display: 'FORMULA (수식)' },
{ value: 'LOOKUP', display: 'LOOKUP (등급표)' },
{ value: 'CUSTOM', display: 'CUSTOM (특수)' }
]
},
width: 130
},
{
type: 'select',
name: 'valueType',
header: 'Value 획득',
record: {
editable: true,
options: [
{ value: '', display: '(미설정)' },
{ value: 'MEASURED', display: 'MEASURED (외부수집)' },
{ value: 'ASSESSED', display: 'ASSESSED (감리자평가)' },
{ value: 'CALCULATED', display: 'CALCULATED (산식)' },
{ value: 'COMPOSITE', display: 'COMPOSITE (복합)' }
]
},
width: 140
},
{ type: 'number',
name: 'weight',
header: '가중치',
record: { editable: true },
filter: true,
sortable: true,
width: 80
},
{ type: 'checkbox',
name: 'active',
label: true,
header: i18next.t('field.active'),
record: { editable: true },
filter: true,
sortable: true,
width: 60
},
{ type: 'string', name: 'state', header: '상태', record: { editable: false }, width: 100 },
{ type: 'string',
name: 'vizType',
hidden: true,
header: '시각화 설정',
record: { editable: false,
renderer: (v, c, r) => { const vizType = r.vizType || 'CARD'
const vizMeta = r.vizMeta || {}
const color = vizMeta.color || '#2196f3'
const icon = vizMeta.icon || 'dashboard'
return html`
this._editViz(r)}> ${vizType}
this._editViz(r)}>
${icon}
`
}
},
width: 150
},
{ type: 'crontab', name: 'schedule', header: '스케줄', record: { editable: true }, width: 120 },
{ type: 'string', name: 'scheduleId', header: '스케줄ID', record: { editable: false }, width: 120 },
{ type: 'timezone', name: 'timezone', header: '타임존', record: { editable: true }, width: 120 },
{ type: 'number', name: 'version', header: '버전', record: { editable: false }, width: 80 },
{ type: 'datetime', name: 'createdAt', header: '생성일', record: { editable: false }, width: 180 },
{ type: 'datetime',
name: 'updatedAt',
header: i18next.t('field.updated_at'),
record: { editable: false },
sortable: true,
width: 180
},
{ type: 'resource-object',
name: 'creator',
header: '생성자',
record: { editable: false, renderer: (v, c, r) => r.creator?.name },
width: 120
},
{ type: 'resource-object',
name: 'updater',
header: i18next.t('field.updater'),
record: { editable: false, renderer: (v, c, r) => r.updater?.name },
sortable: true,
width: 120
},
{ type: 'string', name: 'thumbnail', header: '썸네일', record: { editable: false }, width: 120 }
],
rows: { appendable: false,
selectable: { multiple: true
}
},
sorters: [{ name: 'name' }]
}
}
async pageUpdated(changes: any, lifecycle: any) { if (this.active) { // do something here when this page just became as active
}
}
async fetchHandler({ page = 1, limit = 100, sortings = [], filters = [] }: FetchOption) { if (this.hierarchicalView) { return this.fetchHierarchicalData()
}
const response = await client.query({ query: gql`
query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) { responses: kpis(filters: $filters, pagination: $pagination, sortings: $sortings) { items { id
name
description
active
formula
periodType
scoreType
valueType
scoreFormula
grades
vizType
vizMeta
weight
schedule
scheduleId
timezone
version
parent { id
name
}
children { id
name
description
}
isLeaf
updater { id
name
}
updatedAt
creator { id
name
}
createdAt
}
total
}
}
`,
variables: { filters,
pagination: { page, limit },
sortings
}
})
return { total: response.data.responses.total || 0,
records: response.data.responses.items || []
}
}
async fetchKpiMetrics() { const response = await client.query({ query: gql`
query { kpiMetrics { items { name
description
unit
}
}
}
`
})
if (!response.errors) { this.availableVariables = (response.data.kpiMetrics.items || []).map(metric => ({ name: metric.name,
description: metric.description,
type: 'kpi-metric',
unit: metric.unit
}))
}
}
async _deleteKpi() { if (
await OxPrompt.open({ title: i18next.t('text.are_you_sure'),
text: i18next.t('text.sure_to_x', { x: i18next.t('text.delete') }),
confirmButton: { text: i18next.t('button.confirm') },
cancelButton: { text: i18next.t('button.cancel') }
})
) { const ids = this.grist.selected.map(record => record.id)
if (ids && ids.length > 0) { const response = await client.mutate({ mutation: gql`
mutation ($ids: [String!]!) { deleteKpis(ids: $ids)
}
`,
variables: { ids
}
})
if (!response.errors) { this.grist.fetch()
notify({ message: i18next.t('text.info_x_successfully', { x: i18next.t('text.delete') })
})
}
}
}
}
async _updateKpi() { let patches = this.grist.dirtyRecords
if (patches && patches.length) { patches = patches.map(patch => { let patchField: any = patch.id ? { id: patch.id } : {}
const dirtyFields = patch.__dirtyfields__
for (let key in dirtyFields) { patchField[key] = dirtyFields[key].after
}
patchField.cuFlag = patch.__dirty__
return patchField
})
const response = await client.mutate({ mutation: gql`
mutation ($patches: [KpiPatch!]!) { updateMultipleKpi(patches: $patches) { name
}
}
`,
variables: { patches
}
})
if (!response.errors) { this.grist.fetch()
}
}
}
async creationCallback(kpi) { try { const response = await client.query({ query: gql`
mutation ($kpi: NewKpi!) { createKpi(kpi: $kpi) { id
}
}
`,
variables: { kpi
},
context: { hasUpload: true
}
})
if (!response.errors) { this.grist.fetch()
document.dispatchEvent(
new CustomEvent('notify', { detail: { message: i18next.t('text.data_created_successfully')
}
})
)
}
return true
} catch (ex) { console.error(ex)
document.dispatchEvent(
new CustomEvent('notify', { detail: { type: 'error',
message: i18next.t('text.error')
}
})
)
return false
}
}
async exportHandler() { const exportTargets = this.grist.selected.length ? this.grist.selected : this.grist.dirtyData.records
const targetFieldSet = new Set(['id', 'name', 'description', 'active'])
return exportTargets.map(kpi => { let tempObj = {}
for (const field of targetFieldSet) { tempObj[field] = kpi[field]
}
return tempObj
})
}
async importHandler(records) { const popup = openPopup(
html`
{ history.back()
this.grist.fetch()
}}
>
`,
{ backdrop: true,
size: 'large',
title: i18next.t('title.import kpi')
}
)
popup.onclosed = () => { this.grist.fetch()
}
}
/**
* grades 셀 렌더러. 서브클래스에서 override 가능.
* CUSTOM scoreType은 renderCustomGradesCell()로 위임.
*/
protected renderGradesCell(kpi: any): any {
const scoreType = inferScoreType(kpi)
let label: string
let hasGrades: boolean
switch (scoreType) {
case KPI_SCORE_TYPE.LOOKUP:
hasGrades = Array.isArray(kpi.grades) && kpi.grades.length > 0
label = hasGrades ? `${kpi.grades.length}개 등급 설정됨` : '등급 설정 없음'
break
case KPI_SCORE_TYPE.DIRECT:
// valueType으로 세분화 표시
if (kpi.valueType === KPI_VALUE_TYPE.ASSESSED) {
return html`평가형 (1~5)`
}
if (kpi.valueType === KPI_VALUE_TYPE.CALCULATED) {
return html`산식 계산`
}
return html`직접 (value=score)`
case KPI_SCORE_TYPE.FORMULA:
return html`산식 변환`
case KPI_SCORE_TYPE.CUSTOM:
return this.renderCustomGradesCell(kpi)
default:
if (!scoreType) {
return html`미설정`
}
hasGrades = false
label = '등급 설정 없음'
}
return html` this._editGrades(kpi)}
>${label}`
}
/**
* CUSTOM scoreType의 grades 셀 렌더링. 서브클래스에서 override하여 구현.
*/
protected renderCustomGradesCell(kpi: any): any {
return html` this._editGrades(kpi)}>커스텀 설정됨`
}
/**
* 등급 편집 팝업. CUSTOM 타입은 _editCustomGrades()로 위임. 서브클래스에서 override 가능.
*/
protected async _editGrades(kpi: any) {
if (!kpi.id) {
notify({ message: 'KPI를 먼저 저장한 후에 등급 설정을 할 수 있습니다.' })
return
}
const scoreType = inferScoreType(kpi)
if (scoreType === KPI_SCORE_TYPE.CUSTOM) {
await this._editCustomGrades(kpi)
return
}
if (scoreType !== KPI_SCORE_TYPE.LOOKUP) return
const popup = await openPopup(html` `, {
title: `${kpi.name} - 등급 설정`,
size: 'large'
})
popup.onclosed = () => { this.grist.fetch() }
}
/**
* CUSTOM scoreType의 등급 편집 팝업. 서브클래스에서 override하여 구현.
*/
protected async _editCustomGrades(kpi: any) {
notify({ message: '커스텀 등급 편집기가 필요합니다.' })
}
async _editViz(kpi: any) { const popup = await openPopup(
html`
this._onVizUpdated(kpi.id, vizType, vizMeta)}
.onCancel=${() => popup.close()}
>
`,
{ title: `${kpi.name} - 시각화 설정`,
size: 'large'
}
)
}
async _onVizUpdated(kpiId: string, vizType: string, vizMeta: any) { try { const response = await client.mutate({ mutation: gql`
mutation ($id: String!, $patch: KpiPatch!) { updateKpi(id: $id, patch: $patch) { id
name
vizType
vizMeta
}
}
`,
variables: { id: kpiId,
patch: { vizType, vizMeta }
}
})
if (!response.errors) { this.grist.fetch()
notify({ message: '시각화 설정이 성공적으로 업데이트되었습니다.'
})
}
} catch (error) { notify({ message: '시각화 설정 업데이트 중 오류가 발생했습니다.'
})
}
}
async _calculateKpiValue(kpi) { try { const response = await client.mutate({ mutation: gql`
mutation ($kpiId: String!) { calculateKpiValue(kpiId: $kpiId) { id
value
valueDate
org
}
}
`,
variables: { kpiId: kpi.id
}
})
if (!response.errors) { notify({ message: 'KPI 실적값이 성공적으로 계산되었습니다.' })
this.grist.fetch()
}
} catch (error) { notify({ message: 'KPI 실적값 계산 중 오류가 발생했습니다.' })
}
}
_toggleHierarchicalView() { this.hierarchicalView = !this.hierarchicalView
this.grist.fetch()
}
async fetchHierarchicalData() { const response = await client.query({ query: gql`
query { kpiTree { id
name
description
active
formula
periodType
scoreType
scoreFormula
grades
vizType
vizMeta
weight
schedule
scheduleId
timezone
version
parent { id
name
}
children { id
name
description
active
isLeaf
weight
children { id
name
description
active
isLeaf
weight
children { id
name
description
active
isLeaf
weight
}
}
}
isLeaf
updater { id
name
}
updatedAt
creator { id
name
}
createdAt
}
}
`
})
const flattenedRecords = this.flattenTreeData(response.data.kpiTree)
return { total: flattenedRecords.length,
records: flattenedRecords
}
}
flattenTreeData(treeData: any[], level = 0): any[] { const flattened: any[] = []
for (const item of treeData) { const flattenedItem = { ...item,
__level: level,
__hasChildren: item.children && item.children.length > 0,
__expanded: true
}
// Add indentation to name for visual hierarchy
flattenedItem.name = ' '.repeat(level) + (level > 0 ? '└ ' : '') + item.name
flattened.push(flattenedItem)
if (item.children && item.children.length > 0) { flattened.push(...this.flattenTreeData(item.children, level + 1))
}
}
return flattened
}
}