Newer
Older
navi-1 / webclient / src / components / ui / SkillProposal.vue
<template>
  <div class="skill-proposal">
    <div v-if="loading" class="skill-proposal-state">
      <span class="skill-proposal-spinner" aria-hidden="true" />
      {{ t('ui.proposalLoading') }}
    </div>

    <div v-else-if="loadError" class="skill-proposal-state is-error">{{ loadError }}</div>

    <template v-else-if="proposal">
      <div class="skill-proposal-head">
        <div class="skill-proposal-title">{{ proposal.name }}</div>
        <span class="skill-proposal-kind">
          {{ proposal.replaces_id ? t('ui.proposalReplaces') : t('ui.proposalNew') }}
        </span>
      </div>

      <div v-if="proposal.description" class="skill-proposal-description">
        {{ proposal.description }}
      </div>

      <div class="skill-proposal-meta">
        {{ areaLabel }}
        <template v-if="proposal.tags && proposal.tags.length"> ยท {{ proposal.tags.join(', ') }}</template>
      </div>

      <div v-if="proposal.preconditions && proposal.preconditions.length" class="skill-proposal-block">
        <div class="skill-proposal-label">{{ t('ui.proposalPreconditions') }}</div>
        <ul class="skill-proposal-list">
          <li v-for="(item, i) in proposal.preconditions" :key="`pre-${i}`">{{ item }}</li>
        </ul>
      </div>

      <div v-if="proposal.steps && proposal.steps.length" class="skill-proposal-block">
        <div class="skill-proposal-label">{{ t('ui.proposalSteps') }}</div>
        <ol class="skill-proposal-steps">
          <li v-for="(step, i) in proposal.steps" :key="`step-${i}`">{{ step }}</li>
        </ol>
      </div>

      <div v-if="proposal.post_checks && proposal.post_checks.length" class="skill-proposal-block">
        <div class="skill-proposal-label">{{ t('ui.proposalPostChecks') }}</div>
        <ul class="skill-proposal-list">
          <li v-for="(check, i) in proposal.post_checks" :key="`check-${i}`">{{ check }}</li>
        </ul>
      </div>

      <dl class="skill-proposal-hatches">
        <div v-if="proposal.on_deviation" class="skill-proposal-hatch">
          <dt>{{ t('ui.proposalOnDeviation') }}</dt>
          <dd>{{ proposal.on_deviation }}</dd>
        </div>
        <div v-if="proposal.on_blocked" class="skill-proposal-hatch">
          <dt>{{ t('ui.proposalOnBlocked') }}</dt>
          <dd>{{ proposal.on_blocked }}</dd>
        </div>
        <div v-if="proposal.on_already_done" class="skill-proposal-hatch">
          <dt>{{ t('ui.proposalOnAlreadyDone') }}</dt>
          <dd>{{ proposal.on_already_done }}</dd>
        </div>
      </dl>

      <div v-if="proposal.body" class="skill-proposal-block">
        <div class="skill-proposal-body">{{ proposal.body }}</div>
      </div>

      <div v-if="status === 'pending'" class="skill-proposal-actions">
        <button
          type="button"
          class="skill-proposal-btn is-accept"
          :disabled="sending"
          @click="decide('accept')"
        >
          <i class="ph ph-check" aria-hidden="true"></i>
          {{ t('ui.proposalAccept') }}
        </button>
        <button
          type="button"
          class="skill-proposal-btn is-reject"
          :disabled="sending"
          @click="decide('reject')"
        >
          {{ t('ui.proposalReject') }}
        </button>
        <span v-if="actionError || decisionError" class="skill-proposal-error">
          {{ actionError || decisionError }}
        </span>
      </div>

      <div v-else class="skill-proposal-decision" :class="`is-${status}`">
        <i
          class="ph"
          :class="status === 'accepted' ? 'ph-check-circle' : 'ph-x-circle'"
          aria-hidden="true"
        ></i>
        {{ status === 'accepted' ? t('ui.proposalAccepted') : t('ui.proposalRejected') }}
        <span v-if="decisionError" class="skill-proposal-error">{{ decisionError }}</span>
      </div>
    </template>
  </div>
</template>

<script setup>
import { computed, inject, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n/index.js'
import * as api from '@/api/index.js'

const props = defineProps({
  data: { type: Object, default: () => ({}) }
})

const wsSend = inject('wsSend')

const AREA_SLUGS = {
  home: 'settings.skillsAreaHome',
  infrastructure: 'settings.skillsAreaInfrastructure',
  workflows: 'settings.skillsAreaWorkflows',
  general: 'settings.skillsAreaGeneral',
}

const proposal = ref(null)
const loading = ref(true)
const loadError = ref('')
const sending = ref(false)
const actionError = ref('')

const proposalId = computed(() => props.data?.proposal_id || '')

// The payload is what the chat store patches when the decided event arrives;
// the fetched copy is the fallback for a card restored from history.
const status = computed(
  () => props.data?.status || proposal.value?.status || 'pending'
)
const decisionError = computed(() => props.data?.error || '')

const areaLabel = computed(() =>
  t(AREA_SLUGS[proposal.value?.area] || AREA_SLUGS.general)
)

// A decided card or a refusal both end the wait; the status may stay
// 'pending' when the catalog refused the document, so the error is the other
// signal that the request came back.
watch([status, decisionError], () => {
  sending.value = false
})

onMounted(async () => {
  if (!proposalId.value) {
    loading.value = false
    loadError.value = t('ui.proposalNotFound')
    return
  }
  try {
    proposal.value = await api.getSkillProposal(proposalId.value)
  } catch (err) {
    loadError.value = t('ui.proposalNotFound')
    console.error('[SkillProposal]', err)
  } finally {
    loading.value = false
  }
})

function decide(action) {
  if (sending.value) return
  if (typeof wsSend !== 'function') {
    actionError.value = t('ui.proposalOffline')
    return
  }
  actionError.value = ''
  sending.value = true
  wsSend({ type: 'skill_proposal', proposal_id: proposalId.value, action })
}
</script>

<style scoped>
.skill-proposal {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.skill-proposal-state {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.85rem;
  color: var(--gn-color-text-dark);
}

.skill-proposal-state.is-error {
  color: var(--gn-color-error);
}

.skill-proposal-spinner {
  width: 14px;
  height: 14px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: skill-proposal-spin 0.8s linear infinite;
}

@keyframes skill-proposal-spin {
  to { transform: rotate(360deg); }
}

.skill-proposal-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
}

.skill-proposal-title {
  font-size: 1rem;
  font-weight: 600;
  color: var(--gn-color-text-light);
}

.skill-proposal-kind {
  flex: none;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border: 1px solid var(--gn-border-color-muted);
  color: var(--gn-color-text-dark);
}

.skill-proposal-description {
  font-size: 0.87rem;
  line-height: 1.45;
  color: var(--gn-color-text-light);
}

.skill-proposal-meta {
  font-size: 0.75rem;
  color: var(--gn-color-text-dark);
}

.skill-proposal-block {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.skill-proposal-label {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--gn-color-text-dark);
}

.skill-proposal-list,
.skill-proposal-steps {
  margin: 0;
  padding-left: 20px;
  display: flex;
  flex-direction: column;
  gap: 3px;
  font-size: 0.87rem;
  line-height: 1.45;
  color: var(--gn-color-text-light);
}

.skill-proposal-hatches {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.skill-proposal-hatch {
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.skill-proposal-hatch dt {
  font-size: 0.75rem;
  color: var(--gn-color-text-dark);
}

.skill-proposal-hatch dd {
  margin: 0;
  font-size: 0.85rem;
  color: var(--gn-color-text-light);
}

.skill-proposal-body {
  font-size: 0.85rem;
  line-height: 1.5;
  white-space: pre-wrap;
  color: var(--gn-color-text-light);
  border-left: 2px solid var(--gn-border-color-muted);
  padding-left: 10px;
}

.skill-proposal-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  padding-top: 4px;
}

.skill-proposal-btn {
  padding: 9px 16px;
  border: 1px solid var(--gn-border-color-muted);
  background: transparent;
  color: var(--gn-color-text-light);
  font: inherit;
  font-size: 0.87rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.15s;
}

.skill-proposal-btn.is-accept {
  background: var(--gn-color-secondary);
  border-color: var(--gn-color-secondary);
  color: var(--gn-surface-panel);
}

.skill-proposal-btn:not(:disabled):hover {
  opacity: 0.88;
}

.skill-proposal-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.skill-proposal-decision {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.85rem;
}

.skill-proposal-decision.is-accepted {
  color: var(--gn-color-secondary);
}

.skill-proposal-decision.is-rejected {
  color: var(--gn-color-text-dark);
}

.skill-proposal-error {
  font-size: 0.8rem;
  color: var(--gn-color-error);
}
</style>