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`
(this.mode = 'GRID')} ?active=${mode == 'GRID'}>grid_on (this.mode = 'LIST')} ?active=${mode == 'LIST'}>format_list_bulleted (this.mode = 'CARD')} ?active=${mode == 'CARD'}>apps
` } 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 } }