import '@material/web/icon/icon.js'
import '@material/web/button/elevated-button.js'
import '@material/web/textfield/outlined-text-field.js'
import { CommonButtonStyles, CommonHeaderStyles, ScrollbarStyles } from '@operato/styles'
import { PageView } from '@operato/shell'
import { css, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { ScopedElementsMixin } from '@open-wc/scoped-elements'
import { client } from '@operato/graphql'
import { i18next, localize } from '@operato/i18n'
import { notify } from '@operato/layout'
import gql from 'graphql-tag'
interface KpiValueData { kpiId: string
kpiName: string
periodType: string
values: { [date: string]: { value: number | null; score?: number; isDirty?: boolean } }
}
interface EditorCell { date: string
value: number | null
score?: number
isEditable: boolean
isHighlighted: boolean
}
@customElement('kpi-value-editor-page')
export class KpiValueEditorPage extends localize(i18next)(ScopedElementsMixin(PageView)) { static styles = [
CommonHeaderStyles,
ScrollbarStyles,
css`
:host { display: flex;
flex-direction: column;
padding: 20px;
overflow-x: auto;
}
.header { display: flex;
gap: 16px;
align-items: center;
margin-bottom: 20px;
padding: 16px;
background: var(--md-sys-color-surface-container);
border-radius: 8px;
}
.controls { display: flex;
gap: 12px;
align-items: center;
}
.table-container { flex: 1;
overflow: auto;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
}
table { width: 100%;
border-collapse: collapse;
min-width: max-content;
}
th { background: var(--md-sys-color-surface-container-low);
font-weight: 500;
padding: 8px 12px;
border: 1px solid var(--md-sys-color-outline-variant);
min-width: 80px;
height: 120px;
vertical-align: middle;
}
td { padding: 8px 12px;
border: 1px solid var(--md-sys-color-outline-variant);
min-width: 80px;
height: 60px;
text-align: right;
vertical-align: middle;
}
.kpi-header { position: sticky;
left: 0;
top: 0;
z-index: 3;
}
.kpi-name { position: sticky;
left: 0;
background: var(--md-sys-color-surface);
font-weight: 500;
min-width: 200px;
text-align: left;
z-index: 2;
}
tr:hover { background: var(--md-sys-color-surface-container-high);
}
th.date-header { position: sticky;
top: 0;
z-index: 2;
text-align: center;
font-size: 11px;
color: var(--md-sys-color-on-surface-variant);
writing-mode: vertical-rl;
text-orientation: mixed;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
td.editable-cell { cursor: pointer;
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
td.editable-cell:hover { background: var(--md-sys-color-primary-container-high);
}
td.highlighted-cell { background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
font-weight: 500;
}
td.highlighted-cell:hover { background: var(--md-sys-color-secondary-container-high);
}
td.disabled-cell { background: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
cursor: not-allowed;
}
.value-input { width: 100%;
text-align: center;
border: none;
background: transparent;
color: inherit;
font-size: 12px;
padding: 2px;
}
.value-input:focus { outline: 2px solid var(--md-sys-color-primary);
border-radius: 4px;
}
.period-type-badge { font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
margin-left: 8px;
}
.loading { display: flex;
justify-content: center;
align-items: center;
height: 200px;
color: var(--md-sys-color-on-surface-variant);
}
.error { color: var(--md-sys-color-error);
padding: 16px;
text-align: center;
}
.legend { display: flex;
gap: 16px;
margin-top: 16px;
font-size: 12px;
}
.legend-item { display: flex;
align-items: center;
gap: 4px;
}
.legend-color { width: 16px;
height: 16px;
border-radius: 2px;
}
`
]
@property({ type: String }) kpiOrgScopeId: string = ''
@property({ type: String }) org: string = '' // legacy support
@property({ type: String }) startDate: string = ''
@property({ type: String }) endDate: string = ''
@state() private kpis: KpiValueData[] = []
@state() private dates: string[] = []
@state() private loading: boolean = false
@state() private error: string = ''
@state() private editingCell: { kpiId: string; date: string } | null = null
@state() private _existingValues: any[] = []
get context() { return { title: i18next.t('title.kpi value editor'),
actions: [
{ title: i18next.t('button.save'),
action: this._saveValues.bind(this),
...CommonButtonStyles.save
},
{ title: i18next.t('button.cancel'),
action: this._cancel.bind(this),
...CommonButtonStyles.cancel
}
]
}
}
render() { if (this.loading) { return html`
hourglass_empty
데이터를 불러오는 중...
`
}
if (this.error) { return html`
error
${this.error}
`
}
return html`
${this.dates.map(
date => html`
|
${this._formatDate(date)}
|
`
)}
${this.kpis.map(
kpi => html`
|
${kpi.kpiName}
${kpi.periodType}
|
${this.dates.map(
date => html`
this._startEdit(kpi.kpiId, date)}
>
${this._renderCellContent(kpi, date)}
|
`
)}
`
)}
`
}
private _renderKpiCells(kpi: KpiValueData) { const cells = this._getCellsForKpi(kpi)
return cells.map(cell => { const isEditing = this.editingCell?.kpiId === kpi.kpiId && this.editingCell?.date === cell.date
const cellClass = this._getCellClass(cell)
return html`
this._startEdit(kpi.kpiId, cell.date)}>
${isEditing
? html`
this._finishEdit(kpi.kpiId, cell.date, parseFloat(e.target.value) || 0)}
@keydown=${(e: any) => e.key === 'Enter' && e.target.blur()}
autofocus
/>
`
: html` ${cell.value !== null ? cell.value.toLocaleString() : '-'} `}
`
})
}
private _getCellsForKpi(kpi: KpiValueData): EditorCell[] { const cells: EditorCell[] = []
const periodType = kpi.periodType
this.dates.forEach(date => { const isEditable = this._isDateEditableForPeriodType(date, periodType)
const isHighlighted = this._isDateHighlightedForPeriodType(date, periodType)
cells.push({ date,
value: kpi.values[date]?.value || null,
score: kpi.values[date]?.score,
isEditable,
isHighlighted
})
})
return cells
}
private _isDateEditableForPeriodType(date: string, periodType: string): boolean { const targetDate = new Date(date)
switch (periodType) { case 'DAY':
return true // 모든 날짜 편집 가능
case 'WEEK':
// 해당 주의 첫 번째 날짜만 편집 가능
const weekStart = new Date(targetDate)
weekStart.setDate(targetDate.getDate() - targetDate.getDay())
return date === weekStart.toISOString().split('T')[0]
case 'MONTH':
// 해당 월의 첫 번째 날짜만 편집 가능
return targetDate.getDate() === 1
case 'QUARTER':
// 해당 분기의 첫 번째 날짜만 편집 가능
const quarterStart = new Date(targetDate.getFullYear(), Math.floor(targetDate.getMonth() / 3) * 3, 1)
return date === quarterStart.toISOString().split('T')[0]
case 'YEAR':
// 해당 연도의 첫 번째 날짜만 편집 가능
const yearStart = new Date(targetDate.getFullYear(), 0, 1)
return date === yearStart.toISOString().split('T')[0]
default:
return true
}
}
private _isDateHighlightedForPeriodType(date: string, periodType: string): boolean { const targetDate = new Date(date)
switch (periodType) { case 'DAY':
return false // 하이라이트 없음
case 'WEEK':
// 해당 주의 모든 날짜 하이라이트
const weekStart = new Date(targetDate)
weekStart.setDate(targetDate.getDate() - targetDate.getDay())
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 6)
return date >= weekStart.toISOString().split('T')[0] && date <= weekEnd.toISOString().split('T')[0]
case 'MONTH':
// 해당 월의 모든 날짜 하이라이트
const monthStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1)
const monthEnd = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0)
return date >= monthStart.toISOString().split('T')[0] && date <= monthEnd.toISOString().split('T')[0]
case 'QUARTER':
// 해당 분기의 모든 날짜 하이라이트
const quarter = Math.floor(targetDate.getMonth() / 3)
const quarterStart = new Date(targetDate.getFullYear(), quarter * 3, 1)
const quarterEnd = new Date(targetDate.getFullYear(), (quarter + 1) * 3, 0)
return date >= quarterStart.toISOString().split('T')[0] && date <= quarterEnd.toISOString().split('T')[0]
case 'YEAR':
// 해당 연도의 모든 날짜 하이라이트
const yearStart = new Date(targetDate.getFullYear(), 0, 1)
const yearEnd = new Date(targetDate.getFullYear(), 11, 31)
return date >= yearStart.toISOString().split('T')[0] && date <= yearEnd.toISOString().split('T')[0]
default:
return false
}
}
private _getCellClass(cell: EditorCell): string { if (!cell.isEditable) { return 'disabled-cell'
}
if (cell.isHighlighted) { return 'highlighted-cell'
}
if (cell.isEditable) { return 'editable-cell'
}
return ''
}
private _formatDate(date: string): string { const d = new Date(date)
return d.toLocaleDateString('ko-KR', { month: 'numeric', day: 'numeric' })
}
private _startEdit(kpiId: string, date: string) { this.editingCell = { kpiId, date }
}
private _finishEdit(kpiId: string, date: string, value: number) { const kpi = this.kpis.find(k => k.kpiId === kpiId)
if (kpi) { if (!kpi.values[date]) { kpi.values[date] = { value: 0, isDirty: false }
}
// 값이 변경되었는지 확인
const originalValue = this._findExistingValue(kpiId, date)?.value
const isChanged = originalValue !== value
kpi.values[date].value = value
kpi.values[date].isDirty = isChanged
// score는 저장 시 서버에서 계산되므로 여기서는 제거
delete kpi.values[date].score
}
this.editingCell = null
}
private _renderCellContent(kpi: KpiValueData, date: string) { const isEditing = this.editingCell?.kpiId === kpi.kpiId && this.editingCell?.date === date
const value = kpi.values[date]?.value
const score = kpi.values[date]?.score
if (isEditing) { return html`
this._finishEdit(kpi.kpiId, date, parseFloat(e.target.value) || 0)}
@keydown=${(e: any) => e.key === 'Enter' && e.target.blur()}
style="width: 100%; text-align: center; border: none; background: transparent;"
autofocus
/>
`
}
return html`
${value !== null && value !== undefined ? value.toLocaleString() : '클릭하여 입력'}
${score !== null && score !== undefined
? html`Score: ${score.toFixed(3)}`
: ''}
`
}
private _calculateScore(value: number, periodType: string): number { // 간단한 score 계산 로직 (0-1 범위)
// 실제로는 KPI의 formula나 기준값을 사용해야 함
if (value <= 0) return 0
if (value >= 1000) return 1
return Math.min(value / 1000, 1)
}
private async _loadData() { if (!this.startDate || !this.endDate) { this.error = '시작일, 종료일을 모두 입력해주세요.'
return
}
this.loading = true
this.error = ''
try { // KPI 목록 조회
const kpisResponse = await client.query({ query: gql`
query ($filters: [Filter!]) { kpis(filters: $filters) { items { id
name
periodType
active
}
total
}
}
`,
variables: { filters: [{ name: 'active', operator: 'eq', value: true }]
}
})
// KPI Value 데이터 조회
const valuesResponse = await client.query({ query: gql`
query ($filters: [Filter!]) { kpiValues(filters: $filters) { items { id
kpiId
valueDate
value
score
kpiOrgScope { id
org
}
}
total
}
}
`,
variables: { filters: [
...(this.org ? [{ name: 'org', operator: 'eq', value: this.org }] : []),
{ name: 'valueDate', operator: 'between', value: [this.startDate, this.endDate] }
]
}
})
// 날짜 배열 생성
this.dates = this._generateDateArray(this.startDate, this.endDate)
console.log('시작일:', this.startDate)
console.log('종료일:', this.endDate)
console.log('생성된 날짜 배열:', this.dates)
// KPI 목록을 기준으로 데이터 구성
this.kpis = kpisResponse.data.kpis.items.map((kpi: any) => ({ kpiId: kpi.id,
kpiName: kpi.name,
periodType: kpi.periodType,
values: {}
}))
// 기존 KPI Value 데이터 저장
this._existingValues = valuesResponse.data.kpiValues.items
// KPI Value 데이터를 해당 KPI에 매핑
valuesResponse.data.kpiValues.items.forEach((value: any) => { const kpi = this.kpis.find(k => k.kpiId === value.kpiId)
if (kpi) { kpi.values[value.valueDate] = { value: value.value,
score: value.score,
isDirty: false
}
}
})
// KPI Value가 없는 KPI도 빈 값으로 초기화
this.kpis.forEach(kpi => { this.dates.forEach(date => { if (!kpi.values[date]) { kpi.values[date] = { value: null, score: undefined, isDirty: false }
}
})
})
// 디버깅 정보 출력
console.log('KPI 총 개수:', kpisResponse.data.kpis.total)
console.log('KPI 목록:', kpisResponse.data.kpis.items)
console.log('KPI Value 총 개수:', valuesResponse.data.kpiValues.total)
console.log('KPI Value 목록:', valuesResponse.data.kpiValues.items)
console.log('구성된 KPI 데이터:', this.kpis)
console.log('날짜 배열:', this.dates)
// 강제 업데이트
this.requestUpdate()
} catch (error) { console.error('데이터 로드 중 오류:', error)
this.error = '데이터를 불러오는 중 오류가 발생했습니다.'
} finally { this.loading = false
}
}
private _generateDateArray(startDate: string, endDate: string): string[] { const dates: string[] = []
const start = new Date(startDate)
const end = new Date(endDate)
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { dates.push(d.toLocaleDateString('sv-SE'))
}
return dates
}
private async _saveValues() { try { const patches: any[] = []
this.kpis.forEach(kpi => { Object.entries(kpi.values).forEach(([date, data]) => { // dirty 상태인 데이터만 처리
if (data.isDirty && data.value !== null && data.value !== undefined) { const existingValue = this._findExistingValue(kpi.kpiId, date)
if (existingValue) { // 기존 데이터 업데이트
patches.push({ id: existingValue.id,
value: data.value,
org: this.org,
cuFlag: 'M'
})
} else { // 새로운 데이터 생성
patches.push({ kpiId: kpi.kpiId,
valueDate: date,
value: data.value,
org: this.org,
cuFlag: '+'
})
}
}
})
})
if (patches.length === 0) { notify({ message: '저장할 데이터가 없습니다.' })
return
}
// 단일 mutation으로 생성과 업데이트 처리
const response = await client.mutate({ mutation: gql`
mutation ($patches: [KpiValuePatch!]!) { updateMultipleKpiValue(patches: $patches) { id
value
score
valueDate
kpiId
}
}
`,
variables: { patches }
})
if (!response.errors) { // 서버에서 계산된 score로 UI 업데이트
response.data.updateMultipleKpiValue.forEach((savedValue: any) => { const kpi = this.kpis.find(k => k.kpiId === savedValue.kpiId)
if (kpi && kpi.values[savedValue.valueDate]) { kpi.values[savedValue.valueDate].score = savedValue.score
kpi.values[savedValue.valueDate].isDirty = false // 저장 후 dirty 상태 해제
}
})
notify({ message: 'KPI 값이 성공적으로 저장되었습니다.' })
this.requestUpdate() // UI 강제 업데이트
}
} catch (error) { console.error('저장 중 오류:', error)
notify({ message: '저장 중 오류가 발생했습니다.' })
}
}
private _findExistingValue(kpiId: string, date: string) { // 기존 로드된 KPI Value 데이터에서 찾기
return this._existingValues?.find((v: any) => v.kpiId === kpiId && v.valueDate === date)
}
private _cancel() { // 편집 취소 로직
this.editingCell = null
this._loadData() // 원본 데이터로 복원
}
async pageInitialized(lifecycle: any) { // 기본값 설정 - 지난 1개월
if (!this.startDate) { const today = new Date()
const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate())
this.startDate = oneMonthAgo.toLocaleDateString('sv-SE')
}
if (!this.endDate) { const today = new Date()
this.endDate = today.toLocaleDateString('sv-SE')
}
// 페이지 초기화 시 자동으로 데이터 로드
await this._loadData()
}
}