QA plans al sugerir HU + cancel analisis + datepicker shadcn
- project-analyzer: saveAsDrafts genera QA plan por cada HU sugerida - DashboardView: cancelAnalysis con AbortController + mensaje limpio - HuDrafts: DatePicker con Calendar + Popover (shadcn-vue) - HuDrafts: formulario dinámico segun tipo (Epic vs HU/Feature/etc) - components/ui: Popover + Calendar creados - qa_plans: tabla separada (cubre drafts + user_stories existentes)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "kappa-hub",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/vue": "^1.16.0",
|
||||
"@tabler/icons-vue": "^3.44.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
@@ -243,7 +244,7 @@
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="],
|
||||
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.6", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ=="],
|
||||
|
||||
@@ -1309,6 +1310,8 @@
|
||||
|
||||
"recast-x/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"reka-ui/@internationalized/date": ["@internationalized/date@3.12.1", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ=="],
|
||||
|
||||
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"tauri": "npx tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/vue": "^1.16.0",
|
||||
"@tabler/icons-vue": "^3.44.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
|
||||
@@ -10,7 +10,9 @@ import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Send, Pencil, CheckCircle2, Clock } from 'lucide-vue-next'
|
||||
import { Popover } from '@/components/ui/popover'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Plus, Send, Pencil, CheckCircle2, Clock, Calendar as CalendarIcon } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -136,6 +138,23 @@ async function pushAllToKappa() {
|
||||
}
|
||||
}
|
||||
|
||||
function parseDate(s: string): Date | undefined {
|
||||
if (!s) return undefined
|
||||
const d = new Date(s + 'T00:00:00')
|
||||
return isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
|
||||
function formatDate(d: Date | undefined): string {
|
||||
if (!d) return ''
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function updateEpicDate(value: string | number | null, idx: 0 | 1) {
|
||||
const parts = (form.value.code || '').split('|')
|
||||
parts[idx] = String(value ?? '')
|
||||
form.value.code = parts.join('|') || null
|
||||
}
|
||||
|
||||
onMounted(loadDrafts)
|
||||
</script>
|
||||
|
||||
@@ -154,7 +173,6 @@ onMounted(loadDrafts)
|
||||
<!-- Form -->
|
||||
<Card v-if="showForm" class="mb-4">
|
||||
<CardContent class="p-4 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Select v-model="form.item_type">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="t('users.role')" />
|
||||
@@ -165,13 +183,37 @@ onMounted(loadDrafts)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input :model-value="form.story_points ?? ''" @update:model-value="form.story_points = $event ? Number($event) : null" type="number" placeholder="SP" step="0.5" min="0" />
|
||||
</div>
|
||||
<Input v-model="form.title" :placeholder="t('dashboard.title')" />
|
||||
<Input :model-value="form.title" @update:model-value="form.title = String($event ?? '')" :placeholder="t('dashboard.title')" />
|
||||
<Input :model-value="form.description ?? ''" @update:model-value="form.description = String($event ?? '') || null" :placeholder="t('dashboard.description')" />
|
||||
<template v-if="form.item_type === 'E'">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Popover>
|
||||
<template #trigger>
|
||||
<Button variant="outline" class="w-full justify-start text-sm font-normal">
|
||||
<CalendarIcon class="size-4 mr-2" />
|
||||
<span>{{ (form.code || '').split('|')[0] || 'Inicio estimado' }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<Calendar :model-value="parseDate((form.code || '').split('|')[0])" @update:model-value="updateEpicDate(formatDate($event), 0)" />
|
||||
</Popover>
|
||||
<Popover>
|
||||
<template #trigger>
|
||||
<Button variant="outline" class="w-full justify-start text-sm font-normal">
|
||||
<CalendarIcon class="size-4 mr-2" />
|
||||
<span>{{ (form.code || '').split('|')[1] || 'Fin estimado' }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<Calendar :model-value="parseDate((form.code || '').split('|')[1])" @update:model-value="updateEpicDate(formatDate($event), 1)" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<Input :model-value="form.story_points ?? ''" @update:model-value="form.story_points = $event ? Number($event) : null" type="number" placeholder="SP" step="0.5" min="0" />
|
||||
<Input :model-value="form.sprint ?? ''" @update:model-value="form.sprint = String($event ?? '') || null" placeholder="Sprint" />
|
||||
<Input :model-value="form.assigned_to ?? ''" @update:model-value="form.assigned_to = String($event ?? '') || null" placeholder="Asignado a" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="saveDraft">Guardar</Button>
|
||||
<Button size="sm" variant="outline" @click="showForm = false">Cancelar</Button>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: Date
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: Date | undefined] }>()
|
||||
|
||||
const viewDate = ref(new Date())
|
||||
|
||||
const monthNames = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']
|
||||
const dayNames = ['Do','Lu','Ma','Mi','Ju','Vi','Sa']
|
||||
|
||||
const daysInMonth = computed(() => new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 0).getDate())
|
||||
const firstDay = computed(() => new Date(viewDate.value.getFullYear(), viewDate.value.getMonth(), 1).getDay())
|
||||
|
||||
const today = new Date()
|
||||
const selectedDate = ref(props.modelValue)
|
||||
|
||||
function prevMonth() { viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() - 1, 1) }
|
||||
function nextMonth() { viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 1) }
|
||||
|
||||
function selectDay(d: number) {
|
||||
const date = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth(), d)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
selectedDate.value = date
|
||||
emit('update:modelValue', date)
|
||||
}
|
||||
|
||||
function isSelected(d: number) {
|
||||
if (!selectedDate.value) return false
|
||||
return selectedDate.value.getFullYear() === viewDate.value.getFullYear() &&
|
||||
selectedDate.value.getMonth() === viewDate.value.getMonth() &&
|
||||
selectedDate.value.getDate() === d
|
||||
}
|
||||
|
||||
function isToday(d: number) {
|
||||
return today.getFullYear() === viewDate.value.getFullYear() &&
|
||||
today.getMonth() === viewDate.value.getMonth() &&
|
||||
today.getDate() === d
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-3', props.class)">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<Button variant="ghost" size="icon" class="size-7" @click="prevMonth"><ChevronLeft class="size-4" /></Button>
|
||||
<span class="text-sm font-medium">{{ monthNames[viewDate.getMonth()] }} {{ viewDate.getFullYear() }}</span>
|
||||
<Button variant="ghost" size="icon" class="size-7" @click="nextMonth"><ChevronRight class="size-4" /></Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-0 text-center text-xs mb-1">
|
||||
<span v-for="d in dayNames" :key="d" class="text-muted-foreground py-1">{{ d }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-0 text-center text-sm">
|
||||
<span v-for="_ in firstDay" :key="'e'+_" />
|
||||
<button
|
||||
v-for="d in daysInMonth"
|
||||
:key="d"
|
||||
class="py-1.5 rounded hover:bg-muted transition-colors relative"
|
||||
:class="[isSelected(d) ? 'bg-primary text-primary-foreground hover:bg-primary' : '', isToday(d) && !isSelected(d) ? 'bg-accent text-accent-foreground' : '']"
|
||||
@click="selectDay(d)"
|
||||
>{{ d }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Calendar } from './Calendar.vue'
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
type PopoverContentProps,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="trigger" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent v-bind="props" :class="cn('z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none', props.class)">
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Popover } from './Popover.vue'
|
||||
@@ -320,9 +320,9 @@
|
||||
},
|
||||
"hierarchy": {
|
||||
"epic": "Epic",
|
||||
"hu": "HU",
|
||||
"feature": "Feature",
|
||||
"task": "Task",
|
||||
"hu": "HU",
|
||||
"bug": "Bug"
|
||||
},
|
||||
"workitems": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { callAI } from '@/services/ai'
|
||||
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
||||
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
||||
import { generateAndSavePlan } from '@/services/qa-analyzer'
|
||||
import type { EnrichedUserStory } from '@/stores/workitems'
|
||||
|
||||
export interface AnalysisHU {
|
||||
@@ -129,12 +130,16 @@ export async function saveAsDrafts(
|
||||
})
|
||||
if (isDuplicate) { skipped++; continue }
|
||||
|
||||
const draftId = createDraftId()
|
||||
await saveDraft({
|
||||
id: createDraftId(), projectId, title: hu.title,
|
||||
id: draftId, projectId, title: hu.title,
|
||||
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
||||
priority: hu.priority, type: 'U', metadata: '{}',
|
||||
sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
|
||||
})
|
||||
// QA plan: fire-and-forget para no bloquear el guardado
|
||||
generateAndSavePlan(projectId, draftId, hu.title, hu.description, hu.acceptance_criteria.join('\n'))
|
||||
.catch(e => console.error(`[Alpha] QA auto-gen failed for ${hu.title}:`, e))
|
||||
saved++
|
||||
}
|
||||
|
||||
|
||||
@@ -108,10 +108,6 @@ async function pushDraft(d: HuDraftRecord) {
|
||||
})
|
||||
if (res.ok) {
|
||||
d.syncStatus = 'pushed'; await dbSaveDraft(d)
|
||||
// Auto-generar QA plan tras push exitoso de épica
|
||||
generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', '').catch(e =>
|
||||
console.error(`[Alpha] QA plan auto-gen failed for epic ${d.title}:`, e)
|
||||
)
|
||||
} else {
|
||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
||||
}
|
||||
@@ -136,10 +132,6 @@ async function pushDraft(d: HuDraftRecord) {
|
||||
d.kappaId = created.id || undefined
|
||||
d.syncStatus = 'pushed'
|
||||
await dbSaveDraft(d)
|
||||
// Auto-generar QA plan tras push exitoso
|
||||
generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', d.acceptanceCriteria).catch(e =>
|
||||
console.error(`[Alpha] QA plan auto-gen failed for ${d.title}:`, e)
|
||||
)
|
||||
} else {
|
||||
d.syncStatus = 'draft'; await dbSaveDraft(d)
|
||||
}
|
||||
@@ -168,17 +160,26 @@ async function discardDraft(id: string) {
|
||||
|
||||
// ─── Project analysis ────────────────────────────────────
|
||||
const analyzing = ref(false)
|
||||
const analysisAbort = ref<AbortController | null>(null)
|
||||
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
|
||||
const analysisSummary = ref('')
|
||||
|
||||
function cancelAnalysis() {
|
||||
analysisAbort.value?.abort()
|
||||
analyzing.value = false
|
||||
analysisAbort.value = null
|
||||
analysisSummary.value = 'Análisis cancelado'
|
||||
}
|
||||
|
||||
async function runAnalysis() {
|
||||
if (!project.value) return
|
||||
analyzing.value = true
|
||||
analysisAbort.value = new AbortController()
|
||||
analysisResult.value = null
|
||||
analysisSummary.value = ''
|
||||
|
||||
try {
|
||||
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories)
|
||||
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories, analysisAbort.value?.signal)
|
||||
analysisSummary.value = result.summary
|
||||
|
||||
if (result.hus.length > 0) {
|
||||
@@ -188,8 +189,12 @@ async function runAnalysis() {
|
||||
analysisResult.value = { saved: 0, skipped: 0 }
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
||||
analysisSummary.value = 'Análisis cancelado'
|
||||
} else {
|
||||
console.error('[Alpha] Analysis error:', e)
|
||||
analysisSummary.value = `Error: ${e.message}`
|
||||
}
|
||||
analysisResult.value = { saved: 0, skipped: 0 }
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
@@ -313,6 +318,15 @@ const statusLabel = (status: unknown) => {
|
||||
<Sparkles class="size-4" />
|
||||
Análisis completo del proyecto
|
||||
</CardTitle>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="analyzing"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="cancelAnalysis()"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="analyzing"
|
||||
@@ -322,6 +336,7 @@ const statusLabel = (status: unknown) => {
|
||||
<Sparkles v-else class="size-4 mr-1" />
|
||||
{{ analyzing ? 'Analizando...' : 'Generar HUs faltantes' }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
|
||||
<p class="text-muted-foreground">{{ analysisSummary }}</p>
|
||||
|
||||
Reference in New Issue
Block a user