hu_drafts en Dexie + push individual a KAPPA + project-analyzer
- db.ts v4: tabla hu_drafts (id, projectId, title, description, syncStatus) - hu-drafts-db.ts: CRUD Dexie para drafts - project-analyzer.ts: saveAsDrafts() guarda en BD local, no crea en KAPPA - DashboardView: borradores desde Dexie con boton Enviar individual - pushDraft: endpoint /api/userstorys/create/ con payload exacto - pushDraft: elimina draft solo si KAPPA responde ok
This commit is contained in:
+25
-9
@@ -28,37 +28,53 @@ export interface SessionRecord {
|
|||||||
export interface SessionSummaryRecord {
|
export interface SessionSummaryRecord {
|
||||||
sessionId: number
|
sessionId: number
|
||||||
summary: string
|
summary: string
|
||||||
objectives: string // JSON array
|
objectives: string
|
||||||
tasks: string // JSON array
|
tasks: string
|
||||||
commitments: string // JSON array
|
commitments: string
|
||||||
decisions: string // JSON array
|
decisions: string
|
||||||
keyPoints: string // JSON array
|
keyPoints: string
|
||||||
modelUsed: string
|
modelUsed: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectStateRecord {
|
export interface ProjectStateRecord {
|
||||||
projectId: number
|
projectId: number
|
||||||
summary: string
|
summary: string
|
||||||
objectives: string // JSON array (unificado)
|
objectives: string
|
||||||
tasks: string // JSON array (consolidado)
|
tasks: string
|
||||||
commitments: string // JSON array (consolidado)
|
commitments: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HuDraftRecord {
|
||||||
|
id: string
|
||||||
|
projectId: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
acceptanceCriteria: string
|
||||||
|
priority: string
|
||||||
|
type: string
|
||||||
|
sourceSessionId?: number
|
||||||
|
syncStatus: 'draft' | 'pushing' | 'pushed'
|
||||||
|
kappaId?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
const db = new Dexie('alpha-core') as Dexie & {
|
const db = new Dexie('alpha-core') as Dexie & {
|
||||||
settings: Dexie.Table<SettingEntry, string>
|
settings: Dexie.Table<SettingEntry, string>
|
||||||
project_docs: Dexie.Table<ProjectDocRecord, number>
|
project_docs: Dexie.Table<ProjectDocRecord, number>
|
||||||
sessions: Dexie.Table<SessionRecord, number>
|
sessions: Dexie.Table<SessionRecord, number>
|
||||||
session_summaries: Dexie.Table<SessionSummaryRecord, number>
|
session_summaries: Dexie.Table<SessionSummaryRecord, number>
|
||||||
project_state: Dexie.Table<ProjectStateRecord, number>
|
project_state: Dexie.Table<ProjectStateRecord, number>
|
||||||
|
hu_drafts: Dexie.Table<HuDraftRecord, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(3).stores({
|
db.version(4).stores({
|
||||||
settings: '&key',
|
settings: '&key',
|
||||||
project_docs: '&projectId, projectName, updatedAt',
|
project_docs: '&projectId, projectName, updatedAt',
|
||||||
sessions: '++id, projectId, date',
|
sessions: '++id, projectId, date',
|
||||||
session_summaries: '&sessionId',
|
session_summaries: '&sessionId',
|
||||||
project_state: '&projectId',
|
project_state: '&projectId',
|
||||||
|
hu_drafts: '&id, projectId, syncStatus',
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import db, { type HuDraftRecord } from '@/services/db'
|
||||||
|
export type { HuDraftRecord }
|
||||||
|
|
||||||
|
export async function getDrafts(projectId: number): Promise<HuDraftRecord[]> {
|
||||||
|
return db.hu_drafts.where('projectId').equals(projectId).toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDraft(id: string): Promise<HuDraftRecord | undefined> {
|
||||||
|
return db.hu_drafts.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDraft(d: HuDraftRecord) {
|
||||||
|
await db.hu_drafts.put(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDraft(id: string) {
|
||||||
|
await db.hu_drafts.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnpushedDrafts(projectId: number): Promise<HuDraftRecord[]> {
|
||||||
|
return db.hu_drafts.where({ projectId, syncStatus: 'draft' }).toArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraftId(): string {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { callAI } from '@/services/ai'
|
import { callAI } from '@/services/ai'
|
||||||
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
||||||
import type { EnrichedUserStory } from '@/stores/workitems'
|
import type { EnrichedUserStory } from '@/stores/workitems'
|
||||||
|
|
||||||
interface AnalysisHU {
|
export interface AnalysisHU {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
acceptance_criteria: string[]
|
acceptance_criteria: string[]
|
||||||
@@ -109,51 +109,43 @@ export async function analyzeProject(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crea las HUs propuestas en KAPPA (solo las que no existen).
|
* Guarda las HUs propuestas como borradores en la BD local.
|
||||||
* Devuelve cuántas se crearon.
|
* No crea nada en KAPPA — el usuario revisa y envía desde HuDrafts.
|
||||||
*/
|
*/
|
||||||
export async function createMissingHUs(
|
export async function saveAsDrafts(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
analysis: AnalysisResult,
|
analysis: AnalysisResult,
|
||||||
existingHUs: EnrichedUserStory[],
|
existingHUs: EnrichedUserStory[],
|
||||||
): Promise<{ created: number; skipped: number; errors: string[] }> {
|
sourceSessionId?: number,
|
||||||
let created = 0
|
): Promise<{ saved: number; skipped: number }> {
|
||||||
|
let saved = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
const errors: string[] = []
|
|
||||||
|
|
||||||
const existingTitles = new Set(existingHUs.map(h => (h._cleanTitle || h.title).toLowerCase().trim()))
|
|
||||||
|
|
||||||
for (const hu of analysis.hus) {
|
for (const hu of analysis.hus) {
|
||||||
const normalizedTitle = hu.title.toLowerCase().trim()
|
const normalizedTitle = hu.title.toLowerCase().trim()
|
||||||
|
|
||||||
// Verificar duplicado por título similar
|
|
||||||
const isDuplicate = existingHUs.some(ex => {
|
const isDuplicate = existingHUs.some(ex => {
|
||||||
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
||||||
return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et)
|
return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isDuplicate) {
|
if (isDuplicate) { skipped++; continue }
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
await saveDraft({
|
||||||
await kappa.createUserStory({
|
id: createDraftId(),
|
||||||
|
projectId,
|
||||||
title: hu.title,
|
title: hu.title,
|
||||||
description: hu.description + (hu.acceptance_criteria.length > 0
|
description: hu.description,
|
||||||
? `\n\n**Criterios de aceptación:**\n${hu.acceptance_criteria.map(c => `- ${c}`).join('\n')}`
|
acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
||||||
: ''),
|
priority: hu.priority,
|
||||||
initiative: projectId,
|
type: 'U',
|
||||||
priority: hu.priority || 'Media',
|
sourceSessionId,
|
||||||
status: 'todo',
|
syncStatus: 'draft',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
created++
|
saved++
|
||||||
} catch (e: any) {
|
|
||||||
errors.push(`${hu.title}: ${e.message}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, skipped, errors }
|
return { saved, skipped }
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractJSON(text: string): string {
|
function extractJSON(text: string): string {
|
||||||
|
|||||||
+112
-14
@@ -4,11 +4,13 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
||||||
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle } from 'lucide-vue-next'
|
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.vue'
|
import AiProjectChat from '@/components/AiProjectChat.vue'
|
||||||
import { analyzeProject, createMissingHUs } from '@/services/project-analyzer'
|
import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
|
||||||
|
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
||||||
|
import { kappa } from '@/services/kappa-api'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -29,9 +31,73 @@ const emit = defineEmits<{
|
|||||||
'navigate-settings': []
|
'navigate-settings': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// ─── Drafts ──────────────────────────────────────────────
|
||||||
|
const drafts = ref<HuDraftRecord[]>([])
|
||||||
|
const pushingDraftId = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadDrafts() {
|
||||||
|
if (!project.value) return
|
||||||
|
drafts.value = await getDrafts(project.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushDraft(d: HuDraftRecord) {
|
||||||
|
pushingDraftId.value = d.id
|
||||||
|
d.syncStatus = 'pushing'
|
||||||
|
await dbSaveDraft(d)
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('kappa_token')
|
||||||
|
const res = await fetch('/api/userstorys/create/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) },
|
||||||
|
body: JSON.stringify({
|
||||||
|
initiative: String(d.projectId),
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
|
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
|
story_points: '',
|
||||||
|
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
|
||||||
|
sprint: '',
|
||||||
|
asignado_a: [],
|
||||||
|
client_taker: null,
|
||||||
|
characterization_hu: '',
|
||||||
|
has_impairment: false,
|
||||||
|
epic_development: null,
|
||||||
|
feature: '',
|
||||||
|
initial_date: null,
|
||||||
|
end_date: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await deleteDraft(d.id)
|
||||||
|
} else {
|
||||||
|
d.syncStatus = 'draft'
|
||||||
|
await dbSaveDraft(d)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
d.syncStatus = 'draft'
|
||||||
|
await dbSaveDraft(d)
|
||||||
|
} finally {
|
||||||
|
pushingDraftId.value = null
|
||||||
|
await loadDrafts()
|
||||||
|
await workItems.fetchWorkItems(project.value!.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dbSaveDraft(d: HuDraftRecord) {
|
||||||
|
const { saveDraft } = await import('@/services/hu-drafts-db')
|
||||||
|
await saveDraft(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => project.value?.id, () => { if (project.value) loadDrafts() }, { immediate: false })
|
||||||
|
|
||||||
|
async function discardDraft(id: string) {
|
||||||
|
await deleteDraft(id)
|
||||||
|
await loadDrafts()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Project analysis ────────────────────────────────────
|
// ─── Project analysis ────────────────────────────────────
|
||||||
const analyzing = ref(false)
|
const analyzing = ref(false)
|
||||||
const analysisResult = ref<{ created: number; skipped: number; errors: string[] } | null>(null)
|
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
|
||||||
const analysisSummary = ref('')
|
const analysisSummary = ref('')
|
||||||
|
|
||||||
async function runAnalysis() {
|
async function runAnalysis() {
|
||||||
@@ -45,14 +111,15 @@ async function runAnalysis() {
|
|||||||
analysisSummary.value = result.summary
|
analysisSummary.value = result.summary
|
||||||
|
|
||||||
if (result.hus.length > 0) {
|
if (result.hus.length > 0) {
|
||||||
const outcome = await createMissingHUs(project.value.id, result, workItems.userStories)
|
const outcome = await saveAsDrafts(project.value.id, result, workItems.userStories)
|
||||||
analysisResult.value = outcome
|
analysisResult.value = outcome
|
||||||
await workItems.fetchWorkItems(project.value.id)
|
|
||||||
} else {
|
} else {
|
||||||
analysisResult.value = { created: 0, skipped: 0, errors: [] }
|
analysisResult.value = { saved: 0, skipped: 0 }
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
analysisResult.value = { created: 0, skipped: 0, errors: [e.message] }
|
console.error('[Alpha] Analysis error:', e)
|
||||||
|
analysisSummary.value = `Error: ${e.message}`
|
||||||
|
analysisResult.value = { saved: 0, skipped: 0 }
|
||||||
} finally {
|
} finally {
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
}
|
}
|
||||||
@@ -179,19 +246,16 @@ const statusLabel = (status: unknown) => {
|
|||||||
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
|
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
|
||||||
<p class="text-muted-foreground">{{ analysisSummary }}</p>
|
<p class="text-muted-foreground">{{ analysisSummary }}</p>
|
||||||
<div class="flex items-center gap-3 text-xs">
|
<div class="flex items-center gap-3 text-xs">
|
||||||
<span v-if="analysisResult.created > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
<CheckCircle2 class="size-3" /> {{ analysisResult.created }} HUs creadas
|
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
|
||||||
</span>
|
</span>
|
||||||
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||||
{{ analysisResult.skipped }} duplicadas saltadas
|
{{ analysisResult.skipped }} duplicadas saltadas
|
||||||
</span>
|
</span>
|
||||||
<span v-if="analysisResult.created === 0 && analysisResult.skipped === 0 && analysisResult.errors.length === 0" class="text-muted-foreground">
|
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
|
||||||
Todo ya está cubierto. No se requieren nuevas HUs.
|
Todo ya está cubierto. No se requieren nuevas HUs.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="analysisResult.errors.length > 0" class="text-xs text-destructive space-y-0.5">
|
|
||||||
<p v-for="e in analysisResult.errors" :key="e">⚠ {{ e }}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -311,7 +375,41 @@ const statusLabel = (status: unknown) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Borradores -->
|
<!-- Borradores (web) -->
|
||||||
|
<Card v-if="drafts.length > 0" id="dashboard-drafts" class="border-dashed">
|
||||||
|
<CardHeader class="pb-3 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle class="text-sm font-medium">{{ drafts.length }} borradores pendientes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-2">
|
||||||
|
<div v-for="d in drafts" :key="d.id" class="flex items-start gap-3 p-2 rounded-lg border text-sm">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium">{{ d.title }}</p>
|
||||||
|
<p v-if="d.description" class="text-xs text-muted-foreground truncate">{{ d.description }}</p>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" class="text-[10px]">{{ d.priority }}</Badge>
|
||||||
|
<Badge variant="outline" class="text-[10px] font-mono">
|
||||||
|
{{ d.syncStatus === 'pushing' ? 'Enviando...' : d.syncStatus === 'pushed' ? 'Enviada' : 'Borrador' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 shrink-0">
|
||||||
|
<Button size="sm" variant="outline" class="text-xs h-7"
|
||||||
|
:disabled="pushingDraftId === d.id"
|
||||||
|
@click="pushDraft(d)"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="pushingDraftId === d.id" class="size-3 mr-1 animate-spin" />
|
||||||
|
<Send v-else class="size-3 mr-1" />
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" class="text-xs h-7"
|
||||||
|
@click="discardDraft(d.id)"
|
||||||
|
>✕</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Borradores (Tauri) -->
|
||||||
<HuDrafts v-if="project" :initiative-id="project.id" />
|
<HuDrafts v-if="project" :initiative-id="project.id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user