import '@material/web/icon/icon.js' import '@material/web/chips/assist-chip.js' import '@operato/data-grist/ox-empty-note.js' import gql from 'graphql-tag' import { css, html, LitElement } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { i18next } from '@operato/i18n' import { client } from '@operato/graphql' import { notify } from '@operato/layout' import { OxPrompt } from '@operato/popup/ox-prompt.js' import { ScrollbarStyles } from '@operato/styles' /** * 시나리오 버전 히스토리 패널. * * - 릴리즈된 버전 목록(버전번호·코멘트·작성자·시각) * - 각 버전을 현재 draft 와 비교하는 diff 뷰 * - 과거 버전을 현재 draft 로 복사(revert) * * board-ui 의 board-versions 를 시나리오(스텝 배열 diff)에 맞춰 확장한 형태. */ @customElement('scenario-versions') export class ScenarioVersions extends LitElement { static styles = [ ScrollbarStyles, css` :host { display: flex; flex-direction: column; overflow: auto; background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); font: var(--label-font); padding: var(--spacing-medium, 12px); gap: var(--spacing-medium, 12px); } div[card] { position: relative; border: 1px solid var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12)); border-radius: var(--border-radius, 6px); padding: var(--spacing-medium, 12px); text-align: start; } div[head] { display: flex; align-items: center; gap: var(--spacing-small, 6px); } div[head] [ver] { font: var(--subtitle-font, bold 14px sans-serif); color: var(--md-sys-color-primary); display: inline-flex; align-items: center; gap: 3px; } div[head] [meta] { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; opacity: 0.75; font-size: var(--fontsize-small, 12px); } div[comment] { margin-top: 6px; white-space: pre-wrap; color: var(--md-sys-color-on-surface-variant, inherit); } div[actions] { display: flex; gap: var(--spacing-small, 6px); margin-top: 8px; } md-icon { font-size: var(--fontsize-large, 18px); vertical-align: middle; } button { cursor: pointer; border: 1px solid var(--md-sys-color-outline, rgba(0, 0, 0, 0.2)); background: var(--md-sys-color-surface); color: inherit; border-radius: var(--border-radius, 6px); padding: 4px 10px; display: inline-flex; align-items: center; gap: 4px; font: inherit; } button[primary] { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); border-color: transparent; } div[diff] { margin-top: 8px; border-top: 1px dashed var(--md-sys-color-outline-variant, rgba(0, 0, 0, 0.12)); padding-top: 8px; font-size: var(--fontsize-small, 12px); } div[diff] [group] { font-weight: bold; margin: 4px 0 2px; } [change] { display: block; padding: 1px 0; } [change][added] { color: var(--md-sys-color-primary, green); } [change][removed] { color: var(--md-sys-color-error, #b00020); text-decoration: line-through; opacity: 0.8; } [change][modified] { color: var(--status-warning-color, #b26a00); } [change] small { opacity: 0.7; } ox-empty-note { padding: 24px; opacity: 0.7; } ` ] @property({ type: String }) scenarioId?: string @property({ type: String }) scenarioName?: string /** revert(과거→draft 복사) 성공 시 호출 — 목록 자동 갱신용 콜백(속성 바인딩이라 팝업 경계에서도 확실히 동작). */ @property({ attribute: false }) onReverted?: () => void @state() versions: any[] = [] /** version → diff 결과(펼쳐진 경우만 존재) */ @state() diffs: { [version: number]: any } = {} render() { return html` ${this.versions.length == 0 ? /* 빈 상태는 프레임워크 공통 부품(ox-empty-note) 사용 */ html`` : this.versions.map( (version, index) => html`
sell v${version.version} person ${version.updater?.name || 'anonymous'} schedule ${new Date(version.updatedAt).toLocaleString()}
${version.comment ? html`
${version.comment}
` : ''}
${this.diffs[version.version] ? this.renderDiff(this.diffs[version.version]) : ''}
` )} ` } private renderDiff(diff: any) { if (diff.earliest) { return html`
${i18next.t('text.initial version all new')}
` } const fieldDiffs = diff.fieldDiffs || [] const stepDiffs = diff.stepDiffs || [] if (fieldDiffs.length == 0 && stepDiffs.length == 0) { return html`
${i18next.t('text.no difference from previous')}
` } const fmt = (v: any) => (v === null || v === undefined ? '∅' : typeof v === 'object' ? JSON.stringify(v) : String(v)) return html`
${fieldDiffs.length ? html`
${i18next.t('label.scenario fields')}
${fieldDiffs.map( (f: any) => html`${f.field}: ${fmt(f.before)} → ${fmt(f.after)}` )} ` : ''} ${stepDiffs.length ? html`
${i18next.t('label.steps')}
${stepDiffs.map((s: any) => { if (s.changeType == 'added') { return html`+ ${s.name || '(unnamed)'}` } if (s.changeType == 'removed') { return html`− ${s.name || '(unnamed)'}` } const changed = (s.fieldDiffs || []).map((fd: any) => fd.field).join(', ') return html`~ ${s.name || '(unnamed)'} (${changed})` })} ` : ''}
` } firstUpdated() { this.refresh() } async refresh() { if (!this.scenarioId) { return } const response = ( await client.query({ query: gql` query FetchScenarioVersions($id: String!) { scenarioVersions(id: $id) { id version comment updater { name } updatedAt } } `, variables: { id: this.scenarioId }, fetchPolicy: 'no-cache' }) ).data this.versions = response.scenarioVersions || [] } async toggleDiff(version: number, prevVersion?: number) { if (this.diffs[version]) { const { [version]: _removed, ...rest } = this.diffs this.diffs = rest return } // 앞 버전과 비교 = "이 버전이 무엇을 바꿨나"(체인지로그). 최초 버전은 앞 버전이 없어 전체 신규. if (prevVersion === undefined || prevVersion === null) { this.diffs = { ...this.diffs, [version]: { earliest: true } } return } const response = await client.query({ query: gql` query ScenarioVersionDiff($id: String!, $base: Int!, $to: Int!) { scenarioVersionDiff(id: $id, version: $base, toVersion: $to) { fromVersion toVersion fieldDiffs { field before after } stepDiffs { name changeType fieldDiffs { field } } } } `, variables: { id: this.scenarioId, base: prevVersion, to: version }, fetchPolicy: 'no-cache' }) this.diffs = { ...this.diffs, [version]: response.data.scenarioVersionDiff } } async revert(version: number) { const ok = await OxPrompt.open({ type: 'question', title: String(i18next.t('button.copy to draft')), text: String(i18next.t('text.sure to copy version to draft', { version })), confirmButton: { text: String(i18next.t('button.confirm')) }, cancelButton: { text: String(i18next.t('button.cancel')) } }) if (!ok) { return } const response = await client.mutate({ mutation: gql` mutation RevertScenarioVersion($id: String!, $version: Int!) { revertScenarioVersion(id: $id, version: $version) { id version publishState } } `, variables: { id: this.scenarioId, version } }) if (!response.errors) { notify({ message: i18next.t('text.info_x_successfully', { x: i18next.t('text.copy') }) }) this.onReverted?.() this.dispatchEvent(new CustomEvent('reverted', { bubbles: true, composed: true })) } else { notify({ level: 'error', message: response.errors.map(e => e.message).join('\n') }) } } }