Files
Alpha/src/views/DashboardView.vue
T
ricardo 4e90f6f7b2 users: AG Grid reemplazado por tabla nativa paginada + QA indicators
- UsersView: tabla nativa con busqueda, paginacion (15/page), iniciales
- UsersView: eliminada dependencia ag-grid (mantenida en bundle por otros modulos)
- DashboardView: QA metrics con desglose auto/parcial/manual
- DashboardView: seccion expandible con planes QA detallados
- qa-analyzer: fix field name automatizable
- ToDo: K-09 eliminada
2026-05-28 23:49:41 -05:00

556 lines
24 KiB
Vue

<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects'
import { useWorkItemsStore } from '@/stores/workitems'
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle, Send, ChevronDown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import HuDrafts from '@/components/HuDrafts.vue'
import AiProjectChat from '@/components/AiProjectChat.vue'
import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
import { kappa } from '@/services/kappa-api'
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
const { t } = useI18n()
const projects = useProjectsStore()
const workItems = useWorkItemsStore()
const project = computed(() => projects.selected)
const emit = defineEmits<{
'navigate-settings': []
}>()
// ─── QA Plans ───────────────────────────────────────────
const qaPlans = ref<any[]>([])
const generatingQA = ref<string | null>(null)
const expandedQA = ref<string | null>(null)
const qaMetrics = computed(() => {
const plans = qaPlans.value
let auto = 0, manual = 0, parcial = 0, totalCases = 0
for (const p of plans) {
const plan = parseQAPlan(p)
if (!plan) continue
for (const tc of plan.testCases) {
totalCases++
if (tc.automatizable === 'SÍ') auto++
else if (tc.automatizable === 'MANUAL') manual++
else parcial++
}
}
return { total: plans.length, auto, manual, parcial, totalCases }
})
async function loadQAPlans(projectId: number) {
qaPlans.value = await getQAPlans(projectId)
}
async function generateQA(d: HuDraftRecord) {
generatingQA.value = d.id
try {
await generateAndSavePlan(d.projectId, d.id, d.title, d.description || '', d.acceptanceCriteria)
await loadQAPlans(d.projectId)
} catch (e: any) {
console.error('[Alpha] QA generation error:', e)
} finally {
generatingQA.value = null
}
}
function parseQAPlan(record: any): HUQAPlan | null {
try { return JSON.parse(record.plan) as HUQAPlan } catch { return null }
}
function toggleQAExpand(id: string) {
expandedQA.value = expandedQA.value === id ? null : id
}
function qaBadgeColor(a: string) {
if (a === 'SÍ') return 'text-green-600 border-green-300'
if (a === 'MANUAL') return 'text-red-600 border-red-300'
return 'text-amber-600 border-amber-300'
}
// ─── Drafts ──────────────────────────────────────────────
function getEpicLinkedHUs(d: HuDraftRecord): string[] {
try {
const meta = JSON.parse(d.metadata || '{}')
return meta.linkedHuTitles || []
} catch { return [] }
}
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 headers = { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }
if (d.type === 'E') {
// Push épica: buscar HUs vinculadas ya enviadas
const meta = JSON.parse(d.metadata || '{}')
const linkedHuIds: number[] = []
for (const huTitle of meta.linkedHuTitles || []) {
const pushedHu = drafts.value.find(x => x.type !== 'E' && (x.title.toLowerCase().trim() === huTitle.toLowerCase().trim()) && x.kappaId)
if (pushedHu?.kappaId) linkedHuIds.push(pushedHu.kappaId)
}
const res = await fetch('/api/epicdevelopment/create/', {
method: 'POST', headers,
body: JSON.stringify({
initiative: String(d.projectId), name: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
client_taker: null, hu: linkedHuIds,
stimated_start_date: meta.estimatedStart || null,
stimated_end_date: meta.estimatedEnd || null,
}),
})
if (res.ok) {
d.syncStatus = 'pushed'; await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
} else {
// Push HU individual
const res = await fetch('/api/userstorys/create/', {
method: 'POST', headers,
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) {
const created = await res.json()
d.kappaId = created.id || undefined
d.syncStatus = 'pushed'
await dbSaveDraft(d)
} else {
d.syncStatus = 'draft'; await dbSaveDraft(d)
}
}
} catch {
d.syncStatus = 'draft'; await dbSaveDraft(d)
} finally {
pushingDraftId.value = null
await loadDrafts()
await loadQAPlans(project.value!.id)
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(); loadQAPlans(project.value.id) } }, { immediate: false })
async function discardDraft(id: string) {
await deleteDraft(id)
await loadDrafts()
}
// ─── 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, analysisAbort.value?.signal)
analysisSummary.value = result.summary
if (result.hus.length > 0) {
const outcome = await saveAsDrafts(project.value.id, result, workItems.userStories)
analysisResult.value = outcome
} else {
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
}
}
watch(
() => projects.selectedId,
async (id) => {
if (id) {
await workItems.fetchWorkItems(id)
}
},
{ immediate: true }
)
const statusVariant = (status: unknown) => {
const s = String(status ?? '').toLowerCase()
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return 'secondary'
if (['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso'].includes(s)) return 'default'
if (['blocked', 'bloqueado'].includes(s)) return 'destructive'
if (s === 'true') return 'default'
return 'outline'
}
const statusLabel = (status: unknown) => {
const s = String(status ?? '').toLowerCase()
if (s === 'true') return t('status.inProgress')
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return t('status.completed')
if (['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso'].includes(s)) return t('status.inProgress')
if (['blocked', 'bloqueado'].includes(s)) return t('status.blocked')
if (['todo', 'por hacer'].includes(s)) return t('status.todo')
if (['review', 'revisión'].includes(s)) return t('status.review')
if (['testing', 'pruebas'].includes(s)) return t('status.testing')
if (s === 'false') return t('status.todo')
return String(status ?? '—')
}
</script>
<template>
<div v-if="project" class="@container/main flex flex-1 flex-col gap-4 px-4 lg:px-6">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold tracking-tight">{{ project.name }}</h1>
<div class="flex items-center gap-2">
<Badge v-if="project.key" variant="outline" class="text-xs">{{ project.key }}</Badge>
</div>
</div>
<!-- Stats -->
<div id="dashboard-stats" class="grid gap-3 @xl:grid-cols-2 @3xl:grid-cols-5">
<Card id="dashboard-stats-epics" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.epics') }}</CardTitle>
<Layers class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalEpics }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.epicsSubtitle') }}</p>
</CardContent>
</Card>
<Card id="dashboard-stats-hus" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.hus') }}</CardTitle>
<FileText class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div id="dashboard-stats-hus-count" class="text-2xl font-bold">{{ workItems.totalHUs }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.husSubtitle') }}</p>
</CardContent>
</Card>
<Card id="dashboard-stats-inprogress" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.inProgress') }}</CardTitle>
<Activity class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.inProgressHUs }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.activeHus') }}</p>
</CardContent>
</Card>
<Card id="dashboard-stats-qa" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">QA</CardTitle>
<CheckCircle2 class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ qaMetrics.total }}</div>
<p class="text-xs text-muted-foreground">Planes QA · {{ qaMetrics.totalCases }} casos</p>
<div class="flex gap-2 mt-1.5 text-[10px]">
<span class="text-green-600 dark:text-green-400">{{ qaMetrics.auto }} auto</span>
<span class="text-amber-600 dark:text-amber-400">{{ qaMetrics.parcial }} parcial</span>
<span class="text-red-600 dark:text-red-400">{{ qaMetrics.manual }} manual</span>
</div>
</CardContent>
</Card>
<Card id="dashboard-stats-sessions" class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.sessions') }}</CardTitle>
<Clock class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ workItems.totalSessions }}</div>
<p class="text-xs text-muted-foreground">{{ t('dashboard.sessionsSubtitle') }}</p>
</CardContent>
</Card>
</div>
<!-- AI Chat -->
<AiProjectChat
:project-id="project.id"
:project-name="project.name ?? ''"
:project-description="project.description ?? ''"
:epic-count="workItems.totalEpics"
:hu-count="workItems.totalHUs"
@navigate-settings="emit('navigate-settings')"
/>
<!-- Project Analysis -->
<Card id="dashboard-analysis" class="border-dashed">
<CardHeader class="pb-3 flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<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"
@click="runAnalysis()"
>
<Loader2 v-if="analyzing" class="size-4 mr-1 animate-spin" />
<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>
<div class="flex items-center gap-3 text-xs">
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
</span>
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
{{ analysisResult.skipped }} duplicadas saltadas
</span>
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
Todo ya está cubierto. No se requieren nuevas HUs.
</span>
</div>
</CardContent>
</Card>
<!-- 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">
<div class="flex items-center gap-2">
<Badge variant="outline" class="text-[10px]" :class="d.type === 'E' ? 'border-purple-300 text-purple-600' : ''">
{{ d.type === 'E' ? 'Épica' : 'HU' }}
</Badge>
<p class="font-medium">{{ d.title }}</p>
</div>
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
<span v-if="getEpicLinkedHUs(d).length > 0">
HUs vinculadas: {{ getEpicLinkedHUs(d).join(', ') }}
</span>
<span v-else class="italic">Sin HUs vinculadas</span>
</div>
<p v-if="d.description && d.type !== 'E'" 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" :class="d.syncStatus === 'pushed' ? 'text-green-600 border-green-300' : ''">
{{ d.syncStatus === 'pushing' ? 'Enviando...' : d.syncStatus === 'pushed' ? `Enviada (ID ${d.kappaId || '?'})` : 'Borrador' }}
</Badge>
</div>
</div>
<div class="flex gap-1 shrink-0">
<Button v-if="d.syncStatus !== 'pushed'" 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" />
{{ d.type === 'E' ? 'Epica' : 'HU' }}
</Button>
<Button size="sm" variant="outline" class="text-xs h-7"
:disabled="generatingQA === d.id"
@click="generateQA(d)"
>
<Loader2 v-if="generatingQA === d.id" class="size-3 mr-1 animate-spin" />
QA
</Button>
<Button size="sm" variant="ghost" class="text-xs h-7"
@click="discardDraft(d.id)"
></Button>
</div>
</div>
</CardContent>
</Card>
<!-- Epics -->
<template v-if="workItems.epics.length > 0">
<div>
<h3 id="dashboard-epics-heading" class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
{{ t('dashboard.epicsCount', { count: workItems.totalEpics }) }}
</h3>
<div class="space-y-2">
<Card
v-for="epic in workItems.epics"
:key="epic.id"
class="bg-gradient-to-t from-primary/5 to-card shadow-xs dark:bg-card"
>
<CardHeader class="p-4 pb-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold"
:class="getTypeColor(epic._itemType)"
>
{{ getTypeIcon(epic._itemType) }} {{ getTypeLabel(epic._itemType) }}
</span>
<span class="font-mono text-xs text-muted-foreground flex-shrink-0">{{ epic.code || `EP-${epic.id}` }}</span>
<CardTitle class="text-sm truncate">{{ epic._cleanName || epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</CardTitle>
</div>
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">{{ statusLabel(epic.status || '') }}</Badge>
</div>
</CardHeader>
<CardContent v-if="epic.description" class="p-4 pt-0">
<p class="text-xs text-muted-foreground line-clamp-2">{{ epic.description }}</p>
</CardContent>
</Card>
</div>
</div>
</template>
<!-- QA Plans -->
<Card v-if="qaPlans.length > 0" id="dashboard-qa-plans">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<CheckCircle2 class="size-4" />
Planes QA · {{ qaPlans.length }}
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div v-for="p in qaPlans" :key="p.id" class="border rounded-lg p-3 text-sm">
<div class="flex items-center justify-between cursor-pointer" @click="toggleQAExpand(p.id)">
<span class="font-medium">{{ p.huTitle }}</span>
<ChevronDown class="size-4 text-muted-foreground transition-transform" :class="expandedQA === p.id ? 'rotate-180' : ''" />
</div>
<div v-if="expandedQA === p.id" class="mt-2 space-y-2">
<template v-if="parseQAPlan(p) as HUQAPlan">
<div class="text-xs text-muted-foreground">{{ (parseQAPlan(p) as HUQAPlan).acceptanceCriteria?.length || 0 }} criterios, {{ (parseQAPlan(p) as HUQAPlan).testCases.length }} casos de prueba</div>
<table class="w-full text-xs">
<thead><tr class="text-muted-foreground"><th class="text-left py-1">Prueba</th><th class="text-left py-1">Tipo</th><th class="text-left py-1">Herramienta</th></tr></thead>
<tbody>
<tr v-for="(tc, i) in (parseQAPlan(p) as HUQAPlan).testCases" :key="i" class="border-t">
<td class="py-1 pr-2">{{ tc.description }}</td>
<td class="py-1 pr-2"><Badge variant="outline" :class="qaBadgeColor(tc.automatizable)" class="text-[10px]">{{ tc.automatizable }}</Badge></td>
<td class="py-1 text-muted-foreground">{{ tc.tool }}</td>
</tr>
</tbody>
</table>
</template>
</div>
</div>
</CardContent>
</Card>
<!-- Borradores (Tauri) -->
<HuDrafts v-if="project" :initiative-id="project.id" />
<!-- HUs Table -->
<Card id="dashboard-hus-table">
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium">{{ t('dashboard.userStoriesTitle') }}</CardTitle>
<Badge variant="outline" class="text-xs">{{ t('dashboard.husCount', { count: workItems.userStories.length }) }}</Badge>
</CardHeader>
<CardContent>
<Table v-if="workItems.userStories.length > 0">
<TableHeader>
<TableRow>
<TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
<TableHead>{{ t('dashboard.title') }}</TableHead>
<TableHead class="w-[110px]">{{ t('dashboard.status') }}</TableHead>
<TableHead class="w-[90px] text-right">{{ t('dashboard.priority') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="hu in workItems.userStories" :key="hu.id">
<TableCell class="font-mono text-xs text-muted-foreground">{{ hu.code || `HU-${hu.id}` }}</TableCell>
<TableCell>
<span class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold" :class="getTypeColor(hu._itemType)">{{ getTypeLabel(hu._itemType) }}</span>
</TableCell>
<TableCell class="text-sm max-w-[280px] truncate flex items-center gap-1">
<AlertTriangle v-if="hu.has_impairment" class="size-3.5 text-amber-500 flex-shrink-0" title="Tiene impedimentos pendientes" />
<span class="truncate">{{ hu._cleanTitle || hu.title }}</span>
<span v-if="hu._criteriaList?.length" class="group relative inline-flex flex-shrink-0 cursor-help">
<Info class="size-3.5 text-muted-foreground hover:text-foreground transition-colors" />
<div class="absolute bottom-full left-0 mb-2 w-72 p-3 rounded-lg border bg-popover text-popover-foreground text-xs shadow-md opacity-0 group-hover:opacity-100 transition-opacity z-50 pointer-events-none">
<p class="font-semibold mb-1.5 text-[11px] uppercase tracking-wider text-muted-foreground">Criterios de aceptación</p>
<ol class="list-decimal list-inside space-y-1 text-[11px]"><li v-for="(c, i) in hu._criteriaList" :key="i">{{ c }}</li></ol>
</div>
</span>
</TableCell>
<TableCell><Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">{{ statusLabel(hu.status || '') }}</Badge></TableCell>
<TableCell class="text-right text-xs text-muted-foreground">{{ hu.priority || '—' }}</TableCell>
</TableRow>
</TableBody>
</Table>
<p v-else class="text-xs text-muted-foreground/50 italic text-center py-4">{{ t('dashboard.noUserStories') }}</p>
</CardContent>
</Card>
</div>
<div v-else class="flex flex-1 items-center justify-center">
<div class="text-center space-y-2">
<p v-if="projects.loading" class="text-sm text-muted-foreground">{{ t('common.loading') }}</p>
<p v-else class="text-sm text-muted-foreground">{{ t('dashboard.selectProject') }}</p>
</div>
</div>
</template>