Plan Técnico: Rediseño Tab Clínico
Documento de diseño: clinical-tab-redesign.md
Resumen de Cambios
Arquitectura Actual
ClinicalTab.vue
├── OdontogramChart.vue
│ ├── TreatmentBar.vue (mezcla diagnósticos + tratamientos)
│ ├── TimelineSlider.vue
│ └── ToothQuadrant.vue (x4)
└── Treatment Plans Section (inline)
├── TreatmentPlanMiniCard.vue
└── TreatmentPlanDetail.vue (modal)Arquitectura Propuesta
ClinicalTab.vue
├── ClinicalModeToggle.vue (3 tabs)
│
├── [Modo Histórico] HistoryMode.vue
│ ├── TimelineSlider.vue (existente, reusar)
│ ├── OdontogramChart.vue (mode="view-only")
│ └── ChangesList.vue (nuevo)
│
├── [Modo Diagnóstico] DiagnosisMode.vue
│ ├── OdontogramChart.vue (mode="diagnosis")
│ ├── TreatmentBar.vue (mode="diagnosis") ← REUTILIZADO
│ ├── ConditionsList.vue (nuevo)
│ └── DiagnosisCTA.vue (nuevo, contextual)
│
└── [Modo Planes] PlansMode.vue
├── PlansListView.vue (lista de planes)
│ ├── TreatmentPlanMiniCard.vue (existente)
│ └── OdontogramMini.vue (nuevo, resumen)
└── PlanDetailView.vue (vista expandida)
├── OdontogramChart.vue (mode="planning", con highlighting)
├── PlanTreatmentList.vue (nuevo, con hover linking)
└── TreatmentBar.vue (mode="planning") ← REUTILIZADONota:
TreatmentBar.vuese reutiliza con propmodeen lugar de crear componente separado.
Fase 1: Preparación y Refactoring Base
1.1 Separar Catálogo de Tratamientos
Problema: TreatmentBar.vue usa useTreatmentCatalog() que mezcla diagnósticos y tratamientos.
Solución: Añadir campo is_diagnosis al catálogo o usar categorías existentes.
Archivo: frontend/app/composables/useTreatmentCatalog.ts
// Añadir computed para filtrar
const diagnosticTreatments = computed(() =>
catalog.value.filter(t => DIAGNOSTIC_CATEGORIES.includes(t.category))
)
const therapeuticTreatments = computed(() =>
catalog.value.filter(t => !DIAGNOSTIC_CATEGORIES.includes(t.category))
)
const DIAGNOSTIC_CATEGORIES = ['diagnostic', 'existing_condition']Backend (si necesario): Verificar que TreatmentCatalogItem tenga categoría que distinga diagnósticos.
Archivos a modificar:
frontend/app/composables/useTreatmentCatalog.ts- Posiblemente:
backend/app/modules/treatment_catalog/models.py(añadiris_diagnosisbool)
1.2 Extender OdontogramChart con Modos
Archivo: frontend/app/components/odontogram/OdontogramChart.vue
Cambios:
// Props actuales
mode?: 'full' | 'view-only' | 'planning'
// Props nuevos
mode?: 'full' | 'view-only' | 'diagnosis' | 'planning'
highlightedTeeth?: number[] // Para hover linking
onToothHover?: (toothNumber: number | null) => void
planId?: string // Para modo planning, filtrar tratamientos del planComportamiento por modo:
| Modo | Barra | Editable | Timeline | Highlighting |
|---|---|---|---|---|
view-only | No | No | Sí | No |
diagnosis | DiagnosisBar | Sí (solo diagnósticos) | No | No |
planning | TreatmentBar | Sí (solo terapéuticos) | No | Sí |
1.3 Crear Hook para Draft Plans
Archivo: frontend/app/composables/useTreatmentPlans.ts
Añadir:
const draftPlans = computed(() =>
plans.value.filter(p => p.status === 'draft')
)
async function fetchDraftPlans(patientId: string) {
return fetchPlans({ patient_id: patientId, status: 'draft' })
}Fase 2: Componentes Nuevos
2.1 ClinicalModeToggle.vue
Path: frontend/app/components/clinical/ClinicalModeToggle.vue
<script setup lang="ts">
type ClinicalMode = 'history' | 'diagnosis' | 'plans'
const props = defineProps<{
modelValue: ClinicalMode
}>()
const emit = defineEmits<{
'update:modelValue': [mode: ClinicalMode]
}>()
const modes = [
{ value: 'history', label: 'clinical.modes.history', icon: 'i-lucide-history' },
{ value: 'diagnosis', label: 'clinical.modes.diagnosis', icon: 'i-lucide-stethoscope' },
{ value: 'plans', label: 'clinical.modes.plans', icon: 'i-lucide-clipboard-list' },
]
</script>
<template>
<div class="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<UButton
v-for="mode in modes"
:key="mode.value"
:variant="modelValue === mode.value ? 'solid' : 'ghost'"
:icon="mode.icon"
@click="emit('update:modelValue', mode.value)"
>
{{ $t(mode.label) }}
</UButton>
</div>
</template>2.2 HistoryMode.vue
Path: frontend/app/components/clinical/HistoryMode.vue
<script setup lang="ts">
const props = defineProps<{
patientId: string
}>()
const {
timelineDates,
viewingDate,
fetchTimeline,
fetchOdontogramAtDate,
historicalTeeth,
historicalTreatments
} = useOdontogram()
// Cargar timeline al montar
onMounted(() => fetchTimeline(props.patientId))
// Computed para cambios en fecha seleccionada
const changesAtDate = computed(() => {
// Filtrar tratamientos/cambios de la fecha seleccionada
return historicalTreatments.value.filter(t =>
t.recorded_at?.startsWith(viewingDate.value)
)
})
</script>
<template>
<div class="space-y-4">
<!-- Timeline Slider -->
<UCard>
<TimelineSlider
:dates="timelineDates"
:selected-date="viewingDate"
@select="fetchOdontogramAtDate(patientId, $event)"
/>
</UCard>
<!-- Odontograma (solo lectura) -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('clinical.history.stateAt') }} {{ viewingDate }}</span>
</div>
</template>
<OdontogramChart
:patient-id="patientId"
mode="view-only"
/>
</UCard>
<!-- Cambios en esta fecha -->
<UCard v-if="changesAtDate.length">
<template #header>
{{ $t('clinical.history.changesOnDate') }}
</template>
<ChangesList :changes="changesAtDate" />
</UCard>
</div>
</template>2.3 DiagnosisMode.vue
Path: frontend/app/components/clinical/DiagnosisMode.vue
<script setup lang="ts">
const props = defineProps<{
patientId: string
}>()
const emit = defineEmits<{
'create-plan': []
'continue-plan': [planId: string]
}>()
const { treatments, fetchTreatments } = useOdontogram()
const { plans, fetchPatientPlans } = useTreatmentPlans()
// Filtrar solo diagnósticos (status = 'existing')
const conditions = computed(() =>
treatments.value.filter(t => t.status === 'existing')
)
// Planes en borrador para CTA contextual
const draftPlans = computed(() =>
plans.value.filter(p => p.status === 'draft')
)
onMounted(() => {
fetchTreatments(props.patientId)
fetchPatientPlans(props.patientId)
})
</script>
<template>
<div class="space-y-4">
<!-- Odontograma con barra de diagnósticos -->
<UCard>
<OdontogramChart
:patient-id="patientId"
mode="diagnosis"
/>
</UCard>
<!-- Lista de condiciones registradas -->
<UCard>
<template #header>
{{ $t('clinical.diagnosis.registeredConditions') }}
</template>
<ConditionsList :conditions="conditions" />
</UCard>
<!-- CTA Contextual -->
<DiagnosisCTA
:draft-plans="draftPlans"
@create="emit('create-plan')"
@continue="emit('continue-plan', $event)"
/>
</div>
</template>2.4 Modificar TreatmentBar.vue (añadir modo)
Path: frontend/app/components/odontogram/TreatmentBar.vue
Cambio: Añadir prop mode para reutilizar en diagnóstico y planificación.
<script setup lang="ts">
// NUEVOS props
const props = defineProps<{
mode?: 'diagnosis' | 'planning' | 'full' // NUEVO
selectedTreatment?: TreatmentType | null
selectedStatus: TreatmentStatus
selectedPlanId?: string | null
patientId?: string
treatmentPlans?: TreatmentPlan[]
disabled?: boolean
}>()
// Categorías de diagnóstico
const DIAGNOSTIC_CATEGORIES = ['diagnostic', 'existing_condition', 'caries', 'periodontal']
// Filtrar categorías según modo
const visibleCategories = computed(() => {
const all = categories.value
if (props.mode === 'diagnosis') {
return all.filter(c => DIAGNOSTIC_CATEGORIES.includes(c.id))
}
if (props.mode === 'planning') {
return all.filter(c => !DIAGNOSTIC_CATEGORIES.includes(c.id))
}
return all // 'full' = todo (comportamiento actual)
})
// Status forzado en modo diagnóstico
const effectiveStatus = computed(() =>
props.mode === 'diagnosis' ? 'existing' : props.selectedStatus
)
// Controles visibles según modo
const showStatusToggle = computed(() => props.mode !== 'diagnosis')
const showPlanSelector = computed(() =>
props.mode !== 'diagnosis' && effectiveStatus.value === 'planned'
)
</script>
<template>
<div class="treatment-bar">
<!-- Status toggle: oculto en modo diagnóstico -->
<div v-if="showStatusToggle" class="status-toggle">
<!-- ... toggle existing/planned ... -->
</div>
<!-- Plan selector: solo en planning con status=planned -->
<div v-if="showPlanSelector" class="plan-selector">
<!-- ... dropdown planes ... -->
</div>
<!-- Category tabs: filtradas por modo -->
<div class="category-tabs">
<UButton
v-for="cat in visibleCategories"
:key="cat.id"
<!-- ... -->
/>
</div>
<!-- Treatment grid: sin cambios -->
</div>
</template>Uso en DiagnosisMode:
<TreatmentBar
mode="diagnosis"
:patient-id="patientId"
@treatment-select="handleDiagnosisSelect"
/>Uso en PlanDetailView:
<TreatmentBar
mode="planning"
:patient-id="patientId"
:plan-id="plan.id"
:treatment-plans="plans"
@treatment-select="handleTreatmentSelect"
/>2.5 DiagnosisCTA.vue
Path: frontend/app/components/clinical/DiagnosisCTA.vue
<script setup lang="ts">
const props = defineProps<{
draftPlans: TreatmentPlan[]
}>()
const emit = defineEmits<{
'create': []
'continue': [planId: string]
}>()
const selectedDraftId = ref<string>('')
</script>
<template>
<UCard class="bg-primary-50 dark:bg-primary-900/20 border-primary-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-lightbulb" class="text-primary-500" />
<span>{{ $t('clinical.diagnosis.readyToCreatePlan') }}</span>
</div>
<!-- Sin borradores: Crear plan -->
<UButton
v-if="draftPlans.length === 0"
color="primary"
@click="emit('create')"
>
{{ $t('clinical.diagnosis.createPlan') }}
</UButton>
<!-- 1 borrador: Continuar ese plan -->
<UButton
v-else-if="draftPlans.length === 1"
color="primary"
@click="emit('continue', draftPlans[0].id)"
>
{{ $t('clinical.diagnosis.continuePlan', { name: draftPlans[0].title }) }}
</UButton>
<!-- N borradores: Dropdown -->
<div v-else class="flex items-center gap-2">
<USelectMenu
v-model="selectedDraftId"
:options="draftPlans"
option-attribute="title"
value-attribute="id"
:placeholder="$t('clinical.diagnosis.selectPlan')"
/>
<UButton
color="primary"
:disabled="!selectedDraftId"
@click="emit('continue', selectedDraftId)"
>
{{ $t('clinical.diagnosis.continue') }}
</UButton>
</div>
</div>
</UCard>
</template>2.6 PlansMode.vue
Path: frontend/app/components/clinical/PlansMode.vue
<script setup lang="ts">
const props = defineProps<{
patientId: string
initialPlanId?: string // Para abrir directamente un plan (desde CTA diagnóstico)
}>()
const { plans, fetchPatientPlans, fetchPlan } = useTreatmentPlans()
// Vista: 'list' o 'detail'
const view = ref<'list' | 'detail'>('list')
const selectedPlan = ref<TreatmentPlanDetail | null>(null)
async function openPlanDetail(planId: string) {
selectedPlan.value = await fetchPlan(planId)
view.value = 'detail'
}
function backToList() {
view.value = 'list'
selectedPlan.value = null
}
onMounted(async () => {
await fetchPatientPlans(props.patientId)
if (props.initialPlanId) {
openPlanDetail(props.initialPlanId)
}
})
watch(() => props.initialPlanId, (newId) => {
if (newId) openPlanDetail(newId)
})
</script>
<template>
<!-- Vista Lista -->
<PlansListView
v-if="view === 'list'"
:plans="plans"
:patient-id="patientId"
@view-plan="openPlanDetail"
@create-plan="showCreateModal = true"
/>
<!-- Vista Detalle -->
<PlanDetailView
v-else
:plan="selectedPlan"
:patient-id="patientId"
@back="backToList"
@updated="openPlanDetail(selectedPlan.id)"
/>
</template>2.7 PlanDetailView.vue (Vista Expandida con Hover Linking)
Path: frontend/app/components/clinical/PlanDetailView.vue
<script setup lang="ts">
const props = defineProps<{
plan: TreatmentPlanDetail
patientId: string
}>()
// Hover linking state
const hoveredToothNumber = ref<number | null>(null)
const hoveredItemId = ref<string | null>(null)
// Teeth que tienen tratamientos en este plan
const planTeeth = computed(() => {
return [...new Set(
props.plan.items
.filter(item => item.tooth_number)
.map(item => item.tooth_number)
)]
})
// Items del diente hovered
const highlightedItems = computed(() => {
if (!hoveredToothNumber.value) return []
return props.plan.items
.filter(item => item.tooth_number === hoveredToothNumber.value)
.map(item => item.id)
})
// Dientes del item hovered
const highlightedTeeth = computed(() => {
if (!hoveredItemId.value) return []
const item = props.plan.items.find(i => i.id === hoveredItemId.value)
return item?.tooth_number ? [item.tooth_number] : []
})
</script>
<template>
<div class="space-y-4">
<!-- Header con navegación -->
<div class="flex items-center justify-between">
<UButton variant="ghost" icon="i-lucide-arrow-left" @click="emit('back')">
{{ $t('clinical.plans.backToList') }}
</UButton>
<h2 class="text-lg font-semibold">{{ plan.title }}</h2>
<div class="flex gap-2">
<UButton v-if="plan.status === 'draft'" @click="activatePlan">
{{ $t('clinical.plans.activate') }}
</UButton>
<UButton @click="generateBudget">
{{ $t('clinical.plans.generateBudget') }}
</UButton>
</div>
</div>
<!-- Layout dos columnas -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Columna izquierda: Odontograma -->
<UCard>
<OdontogramChart
:patient-id="patientId"
mode="planning"
:plan-id="plan.id"
:highlighted-teeth="highlightedTeeth"
@tooth-hover="hoveredToothNumber = $event"
/>
</UCard>
<!-- Columna derecha: Lista de tratamientos -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('clinical.plans.treatments') }}</span>
<UButton size="sm" icon="i-lucide-plus">
{{ $t('clinical.plans.addTreatment') }}
</UButton>
</div>
</template>
<PlanTreatmentList
:items="plan.items"
:highlighted-items="highlightedItems"
@item-hover="hoveredItemId = $event"
@item-complete="handleComplete"
@item-remove="handleRemove"
/>
<!-- Total -->
<template #footer>
<div class="flex justify-between font-semibold">
<span>{{ $t('clinical.plans.total') }}</span>
<span>{{ formatCurrency(planTotal) }}</span>
</div>
</template>
</UCard>
</div>
<!-- Barra de tratamientos (abajo) -->
<UCard>
<TreatmentBar
:patient-id="patientId"
:plan-id="plan.id"
mode="therapeutic-only"
@treatment-applied="refreshPlan"
/>
</UCard>
</div>
</template>2.8 PlanTreatmentList.vue (con Hover Linking)
Path: frontend/app/components/clinical/PlanTreatmentList.vue
<script setup lang="ts">
const props = defineProps<{
items: PlannedTreatmentItem[]
highlightedItems: string[]
}>()
const emit = defineEmits<{
'item-hover': [itemId: string | null]
'item-complete': [itemId: string]
'item-remove': [itemId: string]
}>()
const pendingItems = computed(() => props.items.filter(i => i.status === 'pending'))
const completedItems = computed(() => props.items.filter(i => i.status === 'completed'))
</script>
<template>
<div class="space-y-2">
<!-- Pendientes -->
<div
v-for="(item, index) in pendingItems"
:key="item.id"
class="p-3 rounded-lg border transition-colors"
:class="{
'bg-yellow-50 border-yellow-300': highlightedItems.includes(item.id),
'hover:bg-gray-50': !highlightedItems.includes(item.id)
}"
@mouseenter="emit('item-hover', item.id)"
@mouseleave="emit('item-hover', null)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-gray-400">{{ index + 1 }}.</span>
<div>
<div class="font-medium">{{ item.catalog_item?.name || item.description }}</div>
<div v-if="item.tooth_number" class="text-sm text-gray-500">
{{ $t('clinical.tooth') }} {{ item.tooth_number }}
<span v-if="item.surfaces?.length">({{ item.surfaces.join(', ') }})</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">{{ formatCurrency(item.price) }}</span>
<UButton size="xs" variant="ghost" icon="i-lucide-check" @click="emit('item-complete', item.id)" />
<UButton size="xs" variant="ghost" color="red" icon="i-lucide-trash-2" @click="emit('item-remove', item.id)" />
</div>
</div>
</div>
<!-- Completados (colapsable) -->
<UAccordion v-if="completedItems.length" :items="[{ label: `Completados (${completedItems.length})`, slot: 'completed' }]">
<template #completed>
<div v-for="item in completedItems" :key="item.id" class="p-2 text-gray-500 line-through">
{{ item.catalog_item?.name }} - {{ $t('clinical.tooth') }} {{ item.tooth_number }}
</div>
</template>
</UAccordion>
</div>
</template>Fase 3: Refactoring ClinicalTab Principal
3.1 Nuevo ClinicalTab.vue
Path: frontend/app/components/patient/ClinicalTab.vue
<script setup lang="ts">
import type { ClinicalMode } from '~/types'
const props = defineProps<{
patientId: string
readonly?: boolean
}>()
const route = useRoute()
const router = useRouter()
// Modo actual (sync con query param)
const currentMode = ref<ClinicalMode>('diagnosis')
// Plan a abrir (para transición desde diagnóstico)
const targetPlanId = ref<string | null>(null)
// Sync mode con URL
watch(currentMode, (mode) => {
router.replace({ query: { ...route.query, clinicalMode: mode } })
})
onMounted(() => {
const queryMode = route.query.clinicalMode as ClinicalMode
if (queryMode && ['history', 'diagnosis', 'plans'].includes(queryMode)) {
currentMode.value = queryMode
}
})
// Handlers para transiciones entre modos
function handleCreatePlan() {
// Abrir modal de crear plan, luego ir a planes
showPlanModal.value = true
}
function handleContinuePlan(planId: string) {
targetPlanId.value = planId
currentMode.value = 'plans'
}
function handlePlanCreated(plan: TreatmentPlan) {
targetPlanId.value = plan.id
currentMode.value = 'plans'
}
</script>
<template>
<div class="space-y-4">
<!-- Toggle de modos -->
<ClinicalModeToggle v-model="currentMode" />
<!-- Contenido según modo -->
<HistoryMode
v-if="currentMode === 'history'"
:patient-id="patientId"
/>
<DiagnosisMode
v-else-if="currentMode === 'diagnosis'"
:patient-id="patientId"
:readonly="readonly"
@create-plan="handleCreatePlan"
@continue-plan="handleContinuePlan"
/>
<PlansMode
v-else
:patient-id="patientId"
:initial-plan-id="targetPlanId"
:readonly="readonly"
/>
<!-- Modal crear plan (compartido) -->
<TreatmentPlanModal
v-model="showPlanModal"
:patient-id="patientId"
@saved="handlePlanCreated"
/>
</div>
</template>Fase 4: Modificaciones Backend (Mínimas)
4.1 Endpoint para Cambios por Fecha
El timeline ya existe pero necesitamos los cambios específicos de una fecha.
Archivo: backend/app/modules/odontogram/router.py
@router.get("/patients/{patient_id}/odontogram/changes")
async def get_changes_at_date(
patient_id: UUID,
date: str = Query(..., description="Date in YYYY-MM-DD format"),
ctx: ClinicContext = Depends(get_clinic_context),
_: None = Depends(require_permission("odontogram.read")),
db: AsyncSession = Depends(get_db),
) -> ApiResponse[list[HistoryEntryWithUser]]:
"""Get all odontogram changes that occurred on a specific date."""
changes = await OdontogramService.get_changes_at_date(
db, ctx.clinic_id, patient_id, date
)
return ApiResponse(data=changes)Archivo: backend/app/modules/odontogram/service.py
@staticmethod
async def get_changes_at_date(
db: AsyncSession,
clinic_id: UUID,
patient_id: UUID,
date_str: str,
) -> list[OdontogramHistory]:
"""Get all changes that occurred on a specific date."""
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
result = await db.execute(
select(OdontogramHistory)
.join(ToothRecord)
.where(
ToothRecord.clinic_id == clinic_id,
ToothRecord.patient_id == patient_id,
func.date(OdontogramHistory.changed_at) == target_date,
)
.order_by(OdontogramHistory.changed_at)
)
return result.scalars().all()4.2 Verificar Catálogo de Tratamientos
Revisar: backend/app/modules/treatment_catalog/models.py
Asegurar que las categorías permitan distinguir diagnósticos de tratamientos:
class TreatmentCategory(str, Enum):
# Diagnósticos / Condiciones existentes
DIAGNOSTIC = "diagnostic"
EXISTING_CONDITION = "existing_condition"
# Terapéuticos
PREVENTIVE = "preventive"
RESTORATIVE = "restorative"
ENDODONTIC = "endodontic"
PROSTHETIC = "prosthetic"
SURGICAL = "surgical"
ORTHODONTIC = "orthodontic"
PERIODONTAL_TREATMENT = "periodontal_treatment"Si no existe esta distinción, añadir campo is_diagnosis: bool al modelo.
Fase 5: Internacionalización
5.1 Claves de Traducción
Archivo: frontend/i18n/locales/es.json
{
"clinical": {
"modes": {
"history": "Histórico",
"diagnosis": "Diagnóstico",
"plans": "Planes"
},
"history": {
"stateAt": "Estado a fecha",
"changesOnDate": "Cambios en esta fecha",
"noChanges": "Sin cambios registrados"
},
"diagnosis": {
"registeredConditions": "Condiciones registradas",
"readyToCreatePlan": "¿Diagnóstico completo?",
"createPlan": "Crear Plan de Tratamiento",
"continuePlan": "Continuar Plan \"{name}\"",
"selectPlan": "Seleccionar plan",
"continue": "Continuar"
},
"plans": {
"backToList": "Volver a planes",
"treatments": "Tratamientos del Plan",
"addTreatment": "Añadir Tratamiento",
"total": "Total",
"activate": "Activar Plan",
"generateBudget": "Generar Presupuesto"
},
"tooth": "Diente"
}
}Orden de Implementación
Sprint 1: Base
- [ ] Separar catálogo (diagnósticos vs terapéuticos) en
useTreatmentCatalog.ts - [ ] Crear
ClinicalModeToggle.vue - [ ] Extender props de
OdontogramChart.vue(mode, highlightedTeeth, onToothHover) - [ ] Modificar
TreatmentBar.vue(añadir prop mode, filtrar categorías) - [ ] Crear
HistoryMode.vue+ChangesList.vue
Sprint 2: Diagnóstico
- [ ] Crear
ConditionsList.vue - [ ] Crear
DiagnosisCTA.vue - [ ] Crear
DiagnosisMode.vue
Sprint 3: Planes
- [ ] Crear
PlansListView.vue - [ ] Crear
PlanTreatmentList.vue(con hover linking) - [ ] Crear
PlanDetailView.vue - [ ] Crear
PlansMode.vue
Sprint 4: Integración
- [ ] Refactorizar
ClinicalTab.vue - [ ] Añadir endpoint backend cambios por fecha
- [ ] Tests e2e
- [ ] Traducciones
Archivos a Crear (9 componentes)
frontend/app/components/clinical/
├── ClinicalModeToggle.vue
├── HistoryMode.vue
├── ChangesList.vue
├── DiagnosisMode.vue
├── ConditionsList.vue
├── DiagnosisCTA.vue
├── PlansMode.vue
├── PlansListView.vue
├── PlanDetailView.vue
└── PlanTreatmentList.vueArchivos a Modificar
frontend/app/components/
├── patient/ClinicalTab.vue (refactor completo)
├── odontogram/OdontogramChart.vue (añadir props: mode, highlightedTeeth, onToothHover)
└── odontogram/TreatmentBar.vue (añadir prop mode, filtrar categorías según modo)
frontend/app/composables/
├── useTreatmentCatalog.ts (añadir filtros)
└── useTreatmentPlans.ts (añadir draftPlans)
backend/app/modules/odontogram/
├── router.py (añadir endpoint changes)
└── service.py (añadir get_changes_at_date)Riesgos y Mitigaciones
| Riesgo | Impacto | Mitigación |
|---|---|---|
| Catálogo no distingue diagnósticos | Alto | Revisar primero, añadir campo si necesario |
| Performance hover linking | Medio | Debounce en eventos hover |
| Complejidad estado entre modos | Medio | Usar composables compartidos |
| Regresiones en funcionalidad existente | Alto | Tests antes de refactor |
Criterios de Aceptación
- Histórico: Usuario puede navegar timeline y ver estado pasado (solo lectura)
- Diagnóstico: Usuario puede registrar condiciones, ver lista, CTA contextual funciona
- Planes: Vista lista y detalle, hover linking funciona, añadir tratamientos desde odontograma
- Transiciones: Flujo diagnóstico → crear/continuar plan funciona sin fricciones
- Responsive: Funciona en desktop, tablet y móvil
- Permisos: Respeta permisos existentes (odontogram.read/write, treatmentPlans.read/write)