11 Commits

Author SHA1 Message Date
Guillermo.Arrieta 6c744b8c89 Merge pull request 'Merge branch 'main' into issue/225-mejoras-en-el-diseo' (#232) from issue/225-mejoras-en-el-diseo into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m26s
Reviewed-on: #232
2026-04-14 19:04:15 +00:00
Guillermo.Arrieta 18ba592378 Merge branch 'main' into issue/225-mejoras-en-el-diseo 2026-04-14 13:03:16 -06:00
Guillermo.Arrieta 1ede5bca32 Merge branch 'main' into issue/225-mejoras-en-el-diseo 2026-04-14 12:52:14 -06:00
roberto.silva 80cc0a9f2b Merge pull request 'Bug de que no encuentra algunas asignaturas' (#231) from issue/209-bug-de-que-no-encuentra-algunas-asignaturas into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 3m4s
Reviewed-on: #231
2026-04-13 20:46:41 +00:00
roberto.silva 7b437c56e0 Bug de que no encuentra algunas asignaturas
fix #209
2026-04-13 14:45:48 -06:00
Guillermo.Arrieta b37d36fe15 Resumen de cambios del commit y de los dos wips anteriores:
• Cambio del estilo visual del layout del detalle de asignatura
	• El nivel de un plan de estudios se muestra como editable con un select
	• se quitó 'disabled:pointer-events-none' del componente button para permitir que el cursor cambie a cursor-not-allowed sobre botones disabled
	• todos los botones y elementos con role de button no deshabilitados tienen cursor-pointer
	• todos los botones y elementos con role de button deshabilitados tienen cursor-not-allowed
cada cambio tanto en el historial de un plan como de una asignatura se puede abrir picandole en cualquier parte del cambio
2026-04-13 13:54:28 -06:00
Guillermo.Arrieta d3267e5890 wip 2026-04-13 11:04:28 -06:00
Guillermo.Arrieta b77906e2e9 wip 2026-03-27 19:32:37 -06:00
roberto.silva 03cdd24c1d Merge pull request 'dialog warning al cambiar de ciclo una asignatura que rompería la seriación #210' (#230) from issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura- into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m12s
Reviewed-on: #230
2026-03-27 21:57:13 +00:00
roberto.silva a98982d228 Merge branch 'main' into issue/210-dialog-warning-al-cambiar-de-ciclo-una-asignatura- 2026-03-27 15:56:19 -06:00
roberto.silva c710b85090 dialog warning al cambiar de ciclo una asignatura que rompería la seriación
fix #210
2026-03-27 15:17:04 -06:00
17 changed files with 492 additions and 410 deletions
@@ -128,8 +128,6 @@ export default function AsignaturaDetailPage() {
} }
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados]) }, [asignaturaApi, requisitosFormateados])
@@ -182,7 +180,6 @@ function DatosGenerales({
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => { const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
const raw = (data as any)?.criterios_de_evaluacion const raw = (data as any)?.criterios_de_evaluacion
console.log(raw)
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
@@ -232,7 +229,7 @@ function DatosGenerales({
if (isLoading) return <p>Cargando información...</p> if (isLoading) return <p>Cargando información...</p>
return ( return (
<div className="animate-in fade-in space-y-8 pb-8 duration-500"> <div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
{/* Encabezado de la Sección */} {/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center"> <div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div> <div>
@@ -414,8 +411,7 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(initialContent) setTempText(initialContent)
console.log(data)
console.log(initialContent)
if (type === 'evaluation') { if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : [] const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw const rows: Array<CriterioEvaluacionRowDraft> = raw
@@ -457,10 +453,6 @@ function InfoCard({
}, [highlightToken]) }, [highlightToken])
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') { if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = [] const cleaned: Array<CriterioEvaluacionRow> = []
for (const r of evalRows) { for (const r of evalRows) {
@@ -491,8 +483,6 @@ function InfoCard({
return return
} }
if (type === 'requirements') { if (type === 'requirements') {
console.log('entre aqui ')
// Si tempText es un array y tiene elementos, tomamos el ID del primero // Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación) // Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId = const prerequisiteId =
@@ -519,8 +509,6 @@ function InfoCard({
} }
const handleIARequest = (campoClave: string) => { const handleIARequest = (campoClave: string) => {
console.log(campoClave)
let targetClave = campoClave let targetClave = campoClave
if (type === 'evaluation' && !targetClave) { if (type === 'evaluation' && !targetClave) {
targetClave = 'criterios_de_evaluacion' targetClave = 'criterios_de_evaluacion'
@@ -529,8 +517,6 @@ function InfoCard({
if (targetClave === 'contenido') { if (targetClave === 'contenido') {
targetClave = 'contenido_tematico' targetClave = 'contenido_tematico'
} }
console.log(targetClave)
console.log(asignaturaId)
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
@@ -652,18 +638,12 @@ function InfoCard({
: 'none' : 'none'
} }
onValueChange={(val) => { onValueChange={(val) => {
console.log(availableSubjects)
const selected = availableSubjects?.find( const selected = availableSubjects?.find(
(s) => s.id === val, (s) => s.id === val,
) )
if (val === 'none' || !selected) { if (val === 'none' || !selected) {
console.log('guardando')
setTempText([]) setTempText([])
} else { } else {
console.log('hola')
setTempText([ setTempText([
{ {
id: selected.id, id: selected.id,
@@ -677,20 +657,29 @@ function InfoCard({
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<div className="flex-1 truncate text-left"> <div className="flex-1 truncate text-left">
<SelectValue placeholder="Selecciona una materia" /> <SelectValue placeholder="Selecciona una materia">
{Array.isArray(tempText) && tempText.length > 0
? `${tempText[0].code} - ${tempText[0].name}`
: undefined}
</SelectValue>
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none"> <SelectItem value="none">
Ninguna (Sin seriación) Ninguna (Sin seriación)
</SelectItem> </SelectItem>
{availableSubjects?.map((asig) => ( {availableSubjects?.map((asig) => (
<SelectItem <SelectItem
key={asig.id} key={asig.id}
value={asig.id} value={asig.id}
className="max-w-[300px] sm:max-w-[500px]" className="max-w-[300px] sm:max-w-[500px]"
> >
<span className="block truncate"> <span className="text-primary font-bold">
[C{asig.numero_ciclo}]
</span>{' '}
<span className="inline-block truncate">
{asig.codigo} - {asig.nombre} {asig.codigo} - {asig.nombre}
</span> </span>
</SelectItem> </SelectItem>
@@ -11,7 +11,6 @@ import {
Filter, Filter,
Calendar, Calendar,
Loader2, Loader2,
Eye,
} from 'lucide-react' } from 'lucide-react'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
@@ -249,7 +248,16 @@ export function HistorialTab() {
`bg-current ${config.color}`, `bg-current ${config.color}`,
)} )}
/> />
<Card className="card-interactive"> <Card
className="border-border card-interactive hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
onClick={() => openCompareModal(cambio)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
openCompareModal(cambio)
}}
>
<CardContent className="py-4"> <CardContent className="py-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div
@@ -265,16 +273,7 @@ export function HistorialTab() {
<p className="font-medium"> <p className="font-medium">
{cambio.descripcion} {cambio.descripcion}
</p> </p>
{/* BOTÓN PARA VER CAMBIOS */}
<Button
variant="ghost"
size="sm"
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => openCompareModal(cambio)}
>
<Eye className="h-4 w-4" />
Ver cambios
</Button>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{format(cambio.fecha, 'HH:mm')} {format(cambio.fecha, 'HH:mm')}
</span> </span>
@@ -36,7 +36,8 @@ export const AlertaConflicto = ({
))} ))}
</div> </div>
<p className="mt-2 text-xs font-semibold text-slate-500"> <p className="mt-2 text-xs font-semibold text-slate-500">
¿Deseas ignorar la regla y moverla de todos modos? ¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
seriación)?
</p> </p>
</div> </div>
) )
@@ -62,13 +63,8 @@ export const AlertaConflicto = ({
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row"> <div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
<AlertDialog.Cancel asChild> <AlertDialog.Cancel asChild>
<Button <Button variant="ghost">Cancelar</Button>
variant="ghost" {/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
onClick={() => onConfirm()} // Si tu componente espera resolve
className="font-semibold text-slate-500 hover:text-slate-700"
>
Cancelar
</Button>
</AlertDialog.Cancel> </AlertDialog.Cancel>
<AlertDialog.Action asChild> <AlertDialog.Action asChild>
+7 -5
View File
@@ -9,11 +9,13 @@ export function LoginCard() {
return ( return (
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl"> <div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
<img <div className="flex justify-center">
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png" <img
alt="La Salle México" src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
className="mb-6 h-20 w-auto" alt="La Salle México"
/> className="mb-6 h-20 w-auto"
/>
</div>
<h1 className="mb-1 text-center text-2xl font-semibold"> <h1 className="mb-1 text-center text-2xl font-semibold">
Iniciar sesión Iniciar sesión
</h1> </h1>
+20 -3
View File
@@ -14,6 +14,7 @@ interface PlanEstudiosCardProps {
facultad: string facultad: string
estado: string estado: string
claseColorEstado?: string claseColorEstado?: string
colorEstadoHex?: string
colorFacultad: string colorFacultad: string
onClick?: () => void onClick?: () => void
} }
@@ -26,9 +27,17 @@ export default function PlanEstudiosCard({
facultad, facultad,
estado, estado,
claseColorEstado = '', claseColorEstado = '',
colorEstadoHex,
colorFacultad, colorFacultad,
onClick, onClick,
}: PlanEstudiosCardProps) { }: PlanEstudiosCardProps) {
const badgeStyle = colorEstadoHex
? ({
backgroundColor: colorEstadoHex,
borderColor: colorEstadoHex,
} as const)
: undefined
return ( return (
<Card <Card
onClick={onClick} onClick={onClick}
@@ -36,7 +45,7 @@ export default function PlanEstudiosCard({
'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg', 'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
)} )}
> >
<div className="flex flex-grow flex-col"> <div className="flex grow flex-col">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
{/* Círculo del ícono con el color de la facultad */} {/* Círculo del ícono con el color de la facultad */}
<div <div
@@ -63,8 +72,16 @@ export default function PlanEstudiosCard({
</div> </div>
<CardFooter className="flex items-center justify-between pt-0 pb-6"> <CardFooter className="flex items-center justify-between pt-0 pb-6">
<Badge className={cn('text-sm font-semibold', claseColorEstado)}> <Badge
{estado} style={badgeStyle}
className={cn(
'text-sm font-semibold',
!colorEstadoHex && claseColorEstado,
)}
>
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
{estado}
</span>
</Badge> </Badge>
{/* Flecha animada */} {/* Flecha animada */}
-26
View File
@@ -1,26 +0,0 @@
import { StatusBadge } from "./StatusBadge";
export function PlanCard({ plan }: any) {
return (
<div className="bg-[#eaf6fa] rounded-2xl p-6 border hover:shadow-md transition">
<div className="flex justify-between items-start mb-4">
<span className="text-sm text-gray-500"> Ingeniería</span>
<StatusBadge status={plan.status} />
</div>
<h3 className="text-lg font-semibold text-gray-900">
{plan.title}
</h3>
<p className="text-sm text-gray-600 mb-6">
{plan.subtitle}
</p>
<div className="flex justify-between text-sm text-gray-500">
<span>{plan.cycles} ciclos</span>
<span>{plan.credits} créditos</span>
<span></span>
</div>
</div>
)
}
-38
View File
@@ -1,38 +0,0 @@
import { PlanCard } from './PlanCard'
const mockPlans = [
{
id: 1,
name: 'Ingeniería en Sistemas',
level: 'Licenciatura',
status: 'Activo',
},
{
id: 2,
name: 'Arquitectura',
level: 'Licenciatura',
status: 'Activo',
},
{
id: 3,
name: 'Maestría en Educación',
level: 'Maestría',
status: 'Inactivo',
},
]
export function PlanGrid() {
return (
<div>
<h2 className="text-lg font-semibold mb-4">
Planes disponibles
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{mockPlans.map(plan => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
</div>
)
}
-11
View File
@@ -1,11 +0,0 @@
export function StatCard({ icon, value, label }: any) {
return (
<div className="bg-white rounded-xl border p-6">
<div className="flex items-center gap-3 mb-2">
<span className="text-xl">{icon}</span>
<span className="text-2xl font-semibold">{value}</span>
</div>
<p className="text-sm text-gray-500">{label}</p>
</div>
)
}
-12
View File
@@ -1,12 +0,0 @@
import { StatCard } from "./StatCard";
export function StatsGrid() {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard icon="📘" value="12" label="Planes Activos" />
<StatCard icon="🕒" value="4" label="En Revisión" />
<StatCard icon="✅" value="8" label="Aprobados" />
<StatCard icon="👥" value="6" label="Carreras" />
</div>
)
}
-19
View File
@@ -1,19 +0,0 @@
export function StatusBadge({ status }: { status: string }) {
const styles: any = {
revision: 'bg-blue-100 text-blue-700',
aprobado: 'bg-green-100 text-green-700',
borrador: 'bg-gray-200 text-gray-600',
}
return (
<span
className={`text-xs px-3 py-1 rounded-full font-medium ${styles[status]}`}
>
{status === 'revision'
? 'En Revisión'
: status === 'aprobado'
? 'Aprobado'
: 'Borrador'}
</span>
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import type { VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
+80 -50
View File
@@ -8,18 +8,24 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react' import { useState, useEffect, forwardRef } from 'react'
import type { Database } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api' import { plans_get } from '@/data/api/plans.api'
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans' import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
import { cn } from '@/lib/utils'
type NivelPlanEstudio = Database['public']['Enums']['nivel_plan_estudio']
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => { loader: async ({ context: { queryClient }, params: { planId } }) => {
@@ -54,28 +60,26 @@ function RouteComponent() {
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
const [nivelPlan, setNivelPlan] = useState('') const [nivelPlan, setNivelPlan] = useState<NivelPlanEstudio | undefined>(
const [isDirty, setIsDirty] = useState(false) undefined,
)
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setNombrePlan(data.nombre || '') setNombrePlan(data.nombre || '')
setNivelPlan(data.nivel || '') setNivelPlan(data.nivel)
} }
}, [data]) }, [data])
const niveles = [ const niveles: Array<NivelPlanEstudio> = [
'Licenciatura', 'Licenciatura',
'Maestría', 'Maestría',
'Doctorado', 'Doctorado',
'Diplomado',
'Especialidad', 'Especialidad',
'Diplomado',
'Otro',
] ]
const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const MAX_CHARACTERS = 200 const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
@@ -148,18 +152,18 @@ function RouteComponent() {
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
spellCheck={false} spellCheck={false}
aria-label="Nombre del plan"
title="Nombre del plan"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onBlur={(e) => { onBlur={(e) => {
const nuevoNombre = const nuevoNombre = e.currentTarget.textContent.trim()
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre) setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) { if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } }) mutate({ planId, patch: { nombre: nuevoNombre } })
} }
}} }}
className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text sm:inline-block sm:w-auto" className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent wrap-break-word whitespace-pre-wrap no-underline transition-colors outline-none select-text sm:inline-block sm:w-auto"
style={{ textDecoration: 'none' }}
> >
{nombrePlan} {nombrePlan}
</span> </span>
@@ -170,42 +174,68 @@ function RouteComponent() {
</p> </p>
</div> </div>
<div className="flex gap-2"> {(() => {
<Badge className="border-primary/20 bg-primary/10 text-primary hover:bg-primary/20 gap-1 px-3"> const estadoColorHex = (data?.estados_plan as any)?.color as
{data?.estados_plan?.etiqueta} | string
</Badge> | undefined
</div> const badgeStyle = estadoColorHex
? ({
backgroundColor: estadoColorHex,
borderColor: estadoColorHex,
} as const)
: undefined
return (
<Badge
style={badgeStyle}
className={cn(
'text-sm font-semibold',
!estadoColorHex &&
'border-primary/20 bg-primary/10 text-primary hover:bg-primary/20',
)}
>
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
{data?.estados_plan?.etiqueta}
</span>
</Badge>
)
})()}
</div> </div>
)} )}
{/* 3. Cards de Información */} {/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu> <div className="border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all">
<DropdownMenuTrigger asChild> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<InfoCard <GraduationCap className="text-muted-foreground" />
icon={<GraduationCap className="text-muted-foreground" />} </div>
label="Nivel" <div className="min-w-0 flex-1">
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
Nivel
</p>
<Select
value={nivelPlan} value={nivelPlan}
isEditable onValueChange={(value) => {
/> const nuevoNivel = value as NivelPlanEstudio
</DropdownMenuTrigger> setNivelPlan(nuevoNivel)
if (nuevoNivel !== data?.nivel) {
<DropdownMenuContent className="w-48"> mutate({ planId, patch: { nivel: nuevoNivel } })
{niveles.map((n) => ( }
<DropdownMenuItem }}
key={n} >
onClick={() => { <SelectTrigger className="w-full" size="sm">
setNivelPlan(n) <SelectValue placeholder="---" />
if (n !== data?.nivel) { </SelectTrigger>
mutate({ planId, patch: { nivel: n } }) <SelectContent>
} {niveles.map((n) => (
}} <SelectItem key={n} value={n}>
> {n}
{n} </SelectItem>
</DropdownMenuItem> ))}
))} </SelectContent>
</DropdownMenuContent> </Select>
</DropdownMenu> </div>
</div>
<InfoCard <InfoCard
icon={<Clock className="text-muted-foreground" />} icon={<Clock className="text-muted-foreground" />}
@@ -220,7 +250,7 @@ function RouteComponent() {
<InfoCard <InfoCard
icon={<CalendarDays className="text-muted-foreground" />} icon={<CalendarDays className="text-muted-foreground" />}
label="Creación" label="Creación"
value={data?.creado_en?.split('T')[0]} value={data?.creado_en.split('T')[0]}
/> />
</div> </div>
@@ -9,7 +9,6 @@ import {
User, User,
Loader2, Loader2,
Clock, Clock,
Eye,
History, History,
Calendar, Calendar,
ChevronLeft, ChevronLeft,
@@ -156,7 +155,16 @@ function RouteComponent() {
</div> </div>
</div> </div>
<Card className="border-border hover:border-primary/50 flex-1 shadow-none transition-colors"> <Card
className="border-border hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
onClick={() => openCompareModal(event)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
openCompareModal(event)
}}
>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */} {/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
@@ -186,15 +194,6 @@ function RouteComponent() {
</span> </span>
</div> </div>
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="text-primary md:text-muted-foreground md:hover:text-primary group/btn flex items-center gap-1.5 text-xs font-medium"
>
<Eye className="text-muted-foreground/70 group-hover/btn:text-primary h-4 w-4" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */} {/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="text-muted-foreground/70 hidden text-[11px] lg:block"> <span className="text-muted-foreground/70 hidden text-[11px] lg:block">
{format(event.date, 'yyyy-MM-dd HH:mm')} {format(event.date, 'yyyy-MM-dd HH:mm')}
+58 -73
View File
@@ -6,7 +6,6 @@ import { useMemo, useState, useEffect, Fragment, useRef } from 'react'
import type { TipoAsignatura } from '@/data' import type { TipoAsignatura } from '@/data'
import type { Asignatura } from '@/types/plan' import type { Asignatura } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto' import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -402,15 +401,19 @@ function MapaCurricularPage() {
asignatura: Asignatura, asignatura: Asignatura,
nuevoCiclo: number, nuevoCiclo: number,
) => { ) => {
// 1. Esperamos la interacción del usuario
const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo) const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo)
if (!acepto) { // 2. Limpiamos el estado del modal ANTES de seguir
setConfirmState(null) setConfirmState(null)
return
// 3. Verificamos la respuesta
if (acepto === false) {
return // Detenemos la ejecución aquí
} }
// 4. Solo si acepto es true, ejecutamos la API
const patch = { numero_ciclo: nuevoCiclo } const patch = { numero_ciclo: nuevoCiclo }
updateAsignatura( updateAsignatura(
{ asignaturaId: asignatura.id, patch: patch as any }, { asignaturaId: asignatura.id, patch: patch as any },
{ {
@@ -420,12 +423,9 @@ function MapaCurricularPage() {
m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m, m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m,
), ),
) )
setConfirmState(null) // Cerramos el modal de Radix al tener éxito
}, },
onError: (err) => { onError: (err) => {
// En lugar de alert nativo, podrías usar un toast aquí
console.error('Error al actualizar:', err) console.error('Error al actualizar:', err)
setConfirmState(null)
}, },
}, },
) )
@@ -587,48 +587,61 @@ function MapaCurricularPage() {
return val return val
} }
const handleSaveChanges = () => {
if (!editingData) return const procesarCambioAsignatura = async (
setAsignaturas((prev) => asignaturaId: string,
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), nuevosDatos: Partial<Asignatura>,
) ) => {
type AsignaturaPatch = { const asignaturaOriginal = asignaturas.find((a) => a.id === asignaturaId)
codigo?: TablesUpdate<'asignaturas'>['codigo'] if (!asignaturaOriginal) return
nombre?: TablesUpdate<'asignaturas'>['nombre']
tipo?: TablesUpdate<'asignaturas'>['tipo'] // ¿Cambió el ciclo? Si es así, validamos seriación
creditos?: TablesUpdate<'asignaturas'>['creditos'] if (
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas'] nuevosDatos.ciclo !== undefined &&
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes'] nuevosDatos.ciclo !== asignaturaOriginal.ciclo
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo'] ) {
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id'] const acepto = await validarConInterrupcion(
prerrequisito_asignatura_id?: string | null asignaturaId,
nuevosDatos.ciclo,
)
setConfirmState(null)
if (!acepto) return // El usuario canceló, no guardamos nada
} }
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre, // Si llegamos aquí, o no cambió el ciclo o el usuario aceptó el conflicto
codigo: editingData.clave, const patch = {
creditos: editingData.creditos, nombre: nuevosDatos.nombre ?? asignaturaOriginal.nombre,
horas_academicas: editingData.hd, codigo: nuevosDatos.clave ?? asignaturaOriginal.clave,
horas_independientes: editingData.hi, numero_ciclo: nuevosDatos.ciclo,
numero_ciclo: editingData.ciclo, linea_plan_id: nuevosDatos.lineaCurricularId,
linea_plan_id: editingData.lineaCurricularId, creditos: nuevosDatos.creditos,
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id, horas_academicas: nuevosDatos.hd,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA) horas_independientes: nuevosDatos.hi,
prerrequisito_asignatura_id: nuevosDatos.prerrequisito_asignatura_id,
tipo: nuevosDatos.tipo?.toUpperCase() as TipoAsignatura,
} }
updateAsignatura( updateAsignatura(
{ asignaturaId: editingData.id, patch: patch as any }, { asignaturaId, patch: patch as any },
{ {
onSuccess: () => { onSuccess: () => {
setIsEditModalOpen(false) setAsignaturas((prev) =>
// Opcional: Mostrar un toast de éxito prev.map((m) =>
}, m.id === asignaturaId ? { ...m, ...nuevosDatos } : m,
onError: (error) => { ),
console.error('Error al guardar:', error) )
alert('Hubo un error al guardar los cambios.') setIsEditModalOpen(false) // Cerramos el modal si estaba abierto
}, },
onError: (err) => console.error('Error al guardar:', err),
}, },
) )
} }
const handleSaveChanges = () => {
if (!editingData) return
// Llamamos a la lógica centralizada que incluye la alerta
procesarCambioAsignatura(editingData.id, editingData)
}
const unassignedAsignaturas = asignaturas.filter( const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null, (m) => m.ciclo === null || m.lineaCurricularId === null,
) )
@@ -700,41 +713,13 @@ function MapaCurricularPage() {
lineaId: string | null, lineaId: string | null,
) => { ) => {
e.preventDefault() e.preventDefault()
if (!draggedAsignatura) return if (!draggedAsignatura) return
// Buscamos la asignatura completa para tener sus datos // Solo disparamos la lógica si realmente hay un cambio de posición
const asignatura = asignaturas.find((a) => a.id === draggedAsignatura) procesarCambioAsignatura(draggedAsignatura, {
if (!asignatura) return ciclo: cicloDestino,
lineaCurricularId: lineaId,
// SI EL CAMBIO ES DE CICLO (y no solo de línea o a la bandeja) })
// Ejecutamos la validación segura
if (cicloDestino !== null && cicloDestino !== asignatura.ciclo) {
// Llamamos a la función que agregaste
// IMPORTANTE: Asegúrate que handleCambioCicloSeguro esté definida ANTES o sea accesible
await handleCambioCicloSeguro(asignatura, cicloDestino)
// Si la validación interna de handleCambioCicloSeguro falla o el usuario cancela,
// la ejecución se detiene dentro de esa función.
} else {
// CASO B: Es un movimiento a la bandeja (null) o cambio de línea en el mismo ciclo
// Mantenemos la lógica simple de actualización
setAsignaturas((prev) =>
prev.map((m) =>
m.id === draggedAsignatura
? { ...m, ciclo: cicloDestino, lineaCurricularId: lineaId }
: m,
),
)
updateAsignatura(
{
asignaturaId: draggedAsignatura,
patch: { numero_ciclo: cicloDestino, linea_plan_id: lineaId },
},
{ onError: (err) => console.error('Error al mover:', err) },
)
}
setDraggedAsignatura(null) setDraggedAsignatura(null)
} }
@@ -6,12 +6,22 @@ import {
useParams, useParams,
useRouterState, useRouterState,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { ArrowLeft, GraduationCap } from 'lucide-react' import {
ArrowLeft,
GraduationCap,
Pencil,
Hash,
BookOpen,
CalendarDays,
Tag,
} from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti' import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data' import { useSubject, useUpdateAsignatura, usePlanAsignaturas } from '@/data'
import { cn } from '@/lib/utils'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId', '/planes/$planId/asignaturas/$asignaturaId',
@@ -19,65 +29,186 @@ export const Route = createFileRoute(
component: AsignaturaLayout, component: AsignaturaLayout,
}) })
function EditableHeaderField({ // --- 1. COMPONENTE PARA EDITAR EL TÍTULO SOBRE FONDO AZUL ---
function InlineEditTitle({
value, value,
onSave, onSave,
className,
}: { }: {
value: string | number value: string
onSave: (val: string) => void onSave: (val: string) => void
className?: string
}) { }) {
const textValue = String(value) const [isEditing, setIsEditing] = useState(false)
const [tempVal, setTempVal] = useState(value)
// Manejador para cuando el usuario termina de editar (pierde el foco) useEffect(() => setTempVal(value), [value])
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText const handleSave = () => {
if (newValue !== textValue) { setIsEditing(false)
onSave(newValue) if (tempVal.trim() && tempVal !== value) onSave(tempVal.trim())
} else setTempVal(value) // Revertir si está vacío
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => { if (isEditing) {
if (e.key === 'Enter') { return (
e.preventDefault() <input
e.currentTarget.blur() // Forzamos el guardado al presionar Enter autoFocus
} value={tempVal}
onChange={(e) => setTempVal(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setTempVal(value)
setIsEditing(false)
}
}}
// Input estilizado para fondo oscuro: borde blanco sutil, texto blanco
className="focus:ring-primary/40 w-full rounded-md border-2 border-white/20 bg-transparent px-2 py-1 text-3xl font-bold text-white shadow-sm outline-none focus:ring-4"
/>
)
} }
return ( return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions <h1
<span onClick={() => setIsEditing(true)}
contentEditable // Texto blanco por defecto, fondo blanco sutil al hover
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable className="group flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 text-3xl font-bold text-white transition-colors hover:bg-white/5"
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
> >
{textValue} {value}
</span> {/* Lápiz blanco sutil */}
<Pencil className="h-5 w-5 text-white/50 opacity-0 transition-all group-hover:opacity-100 hover:text-white" />
</h1>
) )
} }
// --- 2. COMPONENTE PARA EDITAR LOS BADGES SOBRE FONDO AZUL ---
function InlineEditBadge({
icon,
label,
value,
suffix = '',
type = 'text',
onSave,
}: {
icon: React.ReactNode
label: string
value: string | number
suffix?: string
type?: 'text' | 'number'
onSave: (val: string) => void
}) {
const [isEditing, setIsEditing] = useState(false)
const [tempVal, setTempVal] = useState(value)
useEffect(() => setTempVal(value), [value])
const handleSave = () => {
setIsEditing(false)
if (String(tempVal).trim() !== String(value)) {
onSave(String(tempVal))
}
}
if (isEditing) {
return (
// Contenedor del input con estética de badge oscuro
<div className="focus:ring-primary/40 flex h-8 items-center gap-2 rounded-md border border-white/20 bg-white/5 px-3 shadow-sm ring-1 focus-within:ring-2">
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
{label}:
</span>
<input
autoFocus
type={type}
value={tempVal}
onChange={(e) => setTempVal(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setTempVal(value)
setIsEditing(false)
}
}}
// Texto blanco dentro del input
className="w-16 bg-transparent text-sm font-semibold text-white outline-none"
/>
</div>
)
}
return (
<button
onClick={() => setIsEditing(true)}
// Badge oscuro: borde blanco sutil, texto blanco, fondo más claro al hover
className="group flex h-8 items-center gap-2 rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white transition-all hover:border-white/20 hover:bg-white/10"
>
{/* Ícono blanco sutil */}
<span className="text-white/70">{icon}</span>
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
{label}:
</span>
<span className="font-semibold text-white">
{value} {suffix}
</span>
{/* Lápiz blanco sutil */}
<Pencil className="h-3 w-3 text-white/50 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)
}
interface DatosPlan { interface DatosPlan {
nombre?: string nombre?: string
} }
function AsignaturaLayout() { function AsignaturaLayout() {
const location = useLocation() const location = useLocation()
const { asignaturaId } = useParams({ const { asignaturaId, planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId) useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal const { data: todasLasAsignaturas } = usePlanAsignaturas(planId)
const [confirmState, setConfirmState] = useState<{
isOpen: boolean
resolve: (value: boolean) => void
mensaje: string
} | null>(null)
const validarConInterrupcion = async (
nuevoCiclo: number,
): Promise<boolean> => {
if (!todasLasAsignaturas || !asignaturaApi) return true
const materiasConflicto = todasLasAsignaturas.filter((a) => {
const esPrerrequisitoConflictivo =
asignaturaApi.prerrequisito_asignatura_id === a.id &&
(a.numero_ciclo ?? 0) >= nuevoCiclo
const esDependienteConflictiva =
a.prerrequisito_asignatura_id === asignaturaApi.id &&
(a.numero_ciclo ?? 0) <= nuevoCiclo
return esPrerrequisitoConflictivo || esDependienteConflictiva
})
if (materiasConflicto.length === 0) return true
const listaNombres = materiasConflicto.map((m) => m.nombre)
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
resolve,
mensaje: JSON.stringify({
main: `Mover "${asignaturaApi.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`,
materias: listaNombres,
}),
})
})
}
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({ const [headerData, setHeaderData] = useState({
codigo: '', codigo: '',
nombre: '', nombre: '',
@@ -85,7 +216,6 @@ function AsignaturaLayout() {
ciclo: 0, ciclo: 0,
}) })
// Sincronizar cuando llegue la API
useEffect(() => { useEffect(() => {
if (asignaturaApi) { if (asignaturaApi) {
setHeaderData({ setHeaderData({
@@ -97,28 +227,38 @@ function AsignaturaLayout() {
} }
}, [asignaturaApi]) }, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => { const handleUpdateHeader = async (key: string, value: string | number) => {
// 1. Si es ciclo, validamos antes de hacer nada
if (key === 'ciclo') {
const nuevoCiclo = Number(value)
const acepto = await validarConInterrupcion(nuevoCiclo)
setConfirmState(null) // Cerramos el modal tras la respuesta
if (!acepto) {
// Revertimos el estado local al valor de la API si cancela
setHeaderData((prev) => ({
...prev,
ciclo: asignaturaApi?.numero_ciclo ?? 0,
}))
return
}
}
// 2. Si no es ciclo o si aceptó el conflicto, procedemos
const newData = { ...headerData, [key]: value } const newData = { ...headerData, [key]: value }
setHeaderData(newData) setHeaderData(newData)
const patch: Record<string, any> = const patch: Record<string, any> =
key === 'ciclo' key === 'ciclo' ? { numero_ciclo: value } : { [key]: value }
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({ updateAsignatura.mutate({ asignaturaId, patch })
asignaturaId,
patch,
})
} }
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}) })
// Confetti al llegar desde creación IA
useEffect(() => { useEffect(() => {
if ((location.state as any)?.showConfetti) { if ((location.state as any)?.showConfetti) {
lateralConfetti() lateralConfetti()
@@ -128,91 +268,110 @@ function AsignaturaLayout() {
if (loadingAsig) { if (loadingAsig) {
return ( return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white"> <div className="bg-background text-foreground flex h-screen items-center justify-center">
Cargando asignatura... Cargando asignatura...
</div> </div>
) )
} }
// Si no hay datos y no está cargando, algo falló
if (!asignaturaApi) return null if (!asignaturaApi) return null
return ( return (
<div> <div className="bg-background min-h-screen">
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> {/* HEADER DE LA ASIGNATURA CON TU FONDO AZUL HARDCODEADO */}
<div className="mx-auto p-4 py-10 md:px-6 lg:px-8"> <section className="border-border border-b bg-[#0b1d3a] pt-6 pb-8">
<div className="mx-auto px-4 md:px-6 lg:px-8">
<Link <Link
to="/planes/$planId/asignaturas" to="/planes/$planId/asignaturas"
params={{ planId }} params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white" // Enlace blanco sutil
className="mb-4 flex w-fit items-center gap-2 text-sm text-white/70 transition-colors hover:text-white"
> >
<ArrowLeft className="h-4 w-4" /> Volver al plan <ArrowLeft className="h-4 w-4" /> Volver al plan
</Link> </Link>
<div className="flex items-start justify-between gap-6"> <div className="flex flex-col gap-4">
<div className="space-y-3"> {/* Título Editable (Texto blanco controlado dentro del componente) */}
{/* CÓDIGO EDITABLE */} <div className="-ml-2">
<Badge className="border border-blue-700 bg-blue-900/50"> <InlineEditTitle
<EditableHeaderField value={headerData.nombre}
value={headerData.codigo} onSave={(val) => handleUpdateHeader('nombre', val)}
onSave={(val) => handleUpdateHeader('codigo', val)} />
/>
</Badge>
{/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" />
Pertenece al plan:{' '}
<span className="text-blue-100">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
</span>
</span>
</div>
</div> </div>
<div className="flex flex-col items-end gap-2 text-right"> {/* Fila de Metadatos Alineados */}
{/* CRÉDITOS EDITABLES */} <div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="gap-1"> {/* Badge Estático del Tipo (Estilo oscuro sutil) */}
<span className="inline-flex max-w-fit"> <Badge
<EditableHeaderField variant="outline"
value={headerData.creditos} className="flex h-8 cursor-default items-center gap-1.5 border-white/10 bg-white/5 px-3 text-white"
onSave={(val) => >
handleUpdateHeader('creditos', parseInt(val) || 0) <Tag size={12} className="text-white/70" />
} {asignaturaApi.tipo}
/>
</span>
<span>créditos</span>
</Badge> </Badge>
{/* SEMESTRE EDITABLE */} {/* Badges Editables (Texto blanco controlado dentro de los componentes) */}
<Badge variant="secondary" className="gap-1"> <InlineEditBadge
<EditableHeaderField icon={<Hash size={14} />}
value={headerData.ciclo} label="Código"
onSave={(val) => value={headerData.codigo}
handleUpdateHeader('ciclo', parseInt(val) || 0) onSave={(val) => handleUpdateHeader('codigo', val)}
} />
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturaApi.tipo}</Badge> <InlineEditBadge
icon={<BookOpen size={14} />}
label="Créditos"
type="number"
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
<InlineEditBadge
icon={<CalendarDays size={14} />}
label="Semestre"
type="number"
value={headerData.ciclo}
suffix="°"
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
</div>
{/* Subtítulo de contexto (Texto blanco sutil) */}
<div className="mt-2 flex items-center gap-2 text-sm text-white/70">
<GraduationCap className="h-4 w-4 shrink-0 text-white/60" />
<span>Pertenece al plan:</span>
<span className="font-medium text-white">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* TABS NAVEGACIÓN (Se mantiene semántico para el cuerpo de la página)
<nav className="border-border bg-background/80 sticky top-0 z-20 border-b backdrop-blur-md">
<div className="mx-auto px-4 py-1 md:px-6 lg:px-8">
<div className="scrollbar-hide flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
*/}
{confirmState && (
<AlertaConflicto
isOpen={confirmState.isOpen}
onOpenChange={(open) => {
if (!open) {
confirmState.resolve(false)
setConfirmState(null)
}
}}
onConfirm={() => confirmState.resolve(true)}
titulo="Conflicto de Seriación"
descripcion={confirmState.mensaje}
/>
)}
{/* TABS */} {/* TABS */}
<nav className="sticky top-0 z-20 border-b bg-white"> <nav className="sticky top-0 z-20 border-b bg-white">
@@ -246,11 +405,12 @@ function AsignaturaLayout() {
} }
from="/planes/$planId/asignaturas/$asignaturaId" from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }} params={{ planId, asignaturaId }}
className={`shrink-0 border-b-2 py-4 text-sm font-medium transition-colors ${ className={cn(
'shrink-0 border-b-2 py-3 text-sm font-medium transition-colors',
isActive isActive
? 'border-blue-600 text-blue-600' ? 'border-primary text-primary font-bold'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700' : 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
}`} )}
> >
{tab.label} {tab.label}
</Link> </Link>
+6 -7
View File
@@ -194,9 +194,7 @@ function RouteComponent() {
variant="secondary" variant="secondary"
onClick={resetFilters} onClick={resetFilters}
disabled={isClearDisabled} disabled={isClearDisabled}
className={`shadow-md ${ className={`shadow-md`}
isClearDisabled ? 'cursor-not-allowed opacity-50' : ''
}`}
> >
<Icons.X className="h-4 w-4" /> Limpiar <Icons.X className="h-4 w-4" /> Limpiar
</Button> </Button>
@@ -224,9 +222,9 @@ function RouteComponent() {
// NOTA: El color del estado no viene en BD por defecto, // NOTA: El color del estado no viene en BD por defecto,
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan' // puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
// Aquí uso un fallback simple. // Aquí uso un fallback simple.
const estadoColor = estado?.es_final const estadoColorHex = (estado as any)?.color as
? 'bg-emerald-600' | string
: 'bg-amber-600' | undefined
return ( return (
<PlanEstudiosCard <PlanEstudiosCard
@@ -237,7 +235,8 @@ function RouteComponent() {
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`} ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
facultad={facultad?.nombre ?? 'Sin Facultad'} facultad={facultad?.nombre ?? 'Sin Facultad'}
estado={estado?.etiqueta ?? 'Desconocido'} estado={estado?.etiqueta ?? 'Desconocido'}
claseColorEstado={estadoColor} colorEstadoHex={estadoColorHex}
claseColorEstado={!estadoColorHex ? 'bg-secondary' : ''}
colorFacultad={facultad?.color ?? '#000000'} colorFacultad={facultad?.color ?? '#000000'}
onClick={() => onClick={() =>
navigate({ navigate({
+12
View File
@@ -346,3 +346,15 @@ body {
.login-bg { .login-bg {
background-image: url('/fondo_login.png'); background-image: url('/fondo_login.png');
} }
@layer base {
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role='button']:disabled {
cursor: not-allowed !important;
}
}