Compare commits
11 Commits
36b9c559fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c744b8c89 | |||
| 18ba592378 | |||
| 1ede5bca32 | |||
| 80cc0a9f2b | |||
| 7b437c56e0 | |||
| b37d36fe15 | |||
| d3267e5890 | |||
| b77906e2e9 | |||
| 03cdd24c1d | |||
| a98982d228 | |||
| c710b85090 |
@@ -128,8 +128,6 @@ export default function AsignaturaDetailPage() {
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
console.log(requisitosFormateados)
|
||||
|
||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||
}, [asignaturaApi, requisitosFormateados])
|
||||
|
||||
@@ -182,7 +180,6 @@ function DatosGenerales({
|
||||
|
||||
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
|
||||
const raw = (data as any)?.criterios_de_evaluacion
|
||||
console.log(raw)
|
||||
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
@@ -232,7 +229,7 @@ function DatosGenerales({
|
||||
if (isLoading) return <p>Cargando información...</p>
|
||||
|
||||
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 */}
|
||||
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
@@ -414,8 +411,7 @@ function InfoCard({
|
||||
useEffect(() => {
|
||||
setData(initialContent)
|
||||
setTempText(initialContent)
|
||||
console.log(data)
|
||||
console.log(initialContent)
|
||||
|
||||
if (type === 'evaluation') {
|
||||
const raw = Array.isArray(initialContent) ? initialContent : []
|
||||
const rows: Array<CriterioEvaluacionRowDraft> = raw
|
||||
@@ -457,10 +453,6 @@ function InfoCard({
|
||||
}, [highlightToken])
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('clave, valor:', clave, String(tempText ?? ''))
|
||||
console.log(clave)
|
||||
console.log(tempText)
|
||||
|
||||
if (type === 'evaluation') {
|
||||
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||
for (const r of evalRows) {
|
||||
@@ -491,8 +483,6 @@ function InfoCard({
|
||||
return
|
||||
}
|
||||
if (type === 'requirements') {
|
||||
console.log('entre aqui ')
|
||||
|
||||
// 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)
|
||||
const prerequisiteId =
|
||||
@@ -519,8 +509,6 @@ function InfoCard({
|
||||
}
|
||||
|
||||
const handleIARequest = (campoClave: string) => {
|
||||
console.log(campoClave)
|
||||
|
||||
let targetClave = campoClave
|
||||
if (type === 'evaluation' && !targetClave) {
|
||||
targetClave = 'criterios_de_evaluacion'
|
||||
@@ -529,8 +517,6 @@ function InfoCard({
|
||||
if (targetClave === 'contenido') {
|
||||
targetClave = 'contenido_tematico'
|
||||
}
|
||||
console.log(targetClave)
|
||||
console.log(asignaturaId)
|
||||
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||
@@ -652,18 +638,12 @@ function InfoCard({
|
||||
: 'none'
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
console.log(availableSubjects)
|
||||
|
||||
const selected = availableSubjects?.find(
|
||||
(s) => s.id === val,
|
||||
)
|
||||
if (val === 'none' || !selected) {
|
||||
console.log('guardando')
|
||||
|
||||
setTempText([])
|
||||
} else {
|
||||
console.log('hola')
|
||||
|
||||
setTempText([
|
||||
{
|
||||
id: selected.id,
|
||||
@@ -677,20 +657,29 @@ function InfoCard({
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<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>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
Ninguna (Sin seriación)
|
||||
</SelectItem>
|
||||
|
||||
{availableSubjects?.map((asig) => (
|
||||
<SelectItem
|
||||
key={asig.id}
|
||||
value={asig.id}
|
||||
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}
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Filter,
|
||||
Calendar,
|
||||
Loader2,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
@@ -249,7 +248,16 @@ export function HistorialTab() {
|
||||
`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">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
@@ -265,16 +273,7 @@ export function HistorialTab() {
|
||||
<p className="font-medium">
|
||||
{cambio.descripcion}
|
||||
</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">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
|
||||
@@ -36,7 +36,8 @@ export const AlertaConflicto = ({
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
@@ -62,13 +63,8 @@ export const AlertaConflicto = ({
|
||||
|
||||
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onConfirm()} // Si tu componente espera resolve
|
||||
className="font-semibold text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="ghost">Cancelar</Button>
|
||||
{/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
|
||||
</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action asChild>
|
||||
|
||||
@@ -9,11 +9,13 @@ export function LoginCard() {
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
|
||||
<img
|
||||
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
|
||||
alt="La Salle México"
|
||||
className="mb-6 h-20 w-auto"
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
|
||||
alt="La Salle México"
|
||||
className="mb-6 h-20 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-1 text-center text-2xl font-semibold">
|
||||
Iniciar sesión
|
||||
</h1>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PlanEstudiosCardProps {
|
||||
facultad: string
|
||||
estado: string
|
||||
claseColorEstado?: string
|
||||
colorEstadoHex?: string
|
||||
colorFacultad: string
|
||||
onClick?: () => void
|
||||
}
|
||||
@@ -26,9 +27,17 @@ export default function PlanEstudiosCard({
|
||||
facultad,
|
||||
estado,
|
||||
claseColorEstado = '',
|
||||
colorEstadoHex,
|
||||
colorFacultad,
|
||||
onClick,
|
||||
}: PlanEstudiosCardProps) {
|
||||
const badgeStyle = colorEstadoHex
|
||||
? ({
|
||||
backgroundColor: colorEstadoHex,
|
||||
borderColor: colorEstadoHex,
|
||||
} as const)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Card
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="flex grow flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
{/* Círculo del ícono con el color de la facultad */}
|
||||
<div
|
||||
@@ -63,8 +72,16 @@ export default function PlanEstudiosCard({
|
||||
</div>
|
||||
|
||||
<CardFooter className="flex items-center justify-between pt-0 pb-6">
|
||||
<Badge className={cn('text-sm font-semibold', claseColorEstado)}>
|
||||
{estado}
|
||||
<Badge
|
||||
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>
|
||||
|
||||
{/* Flecha animada */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
@@ -8,18 +8,24 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, forwardRef } from 'react'
|
||||
|
||||
import type { Database } from '@/types/supabase'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { plans_get } from '@/data/api/plans.api'
|
||||
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
|
||||
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')({
|
||||
loader: async ({ context: { queryClient }, params: { planId } }) => {
|
||||
@@ -54,28 +60,26 @@ function RouteComponent() {
|
||||
|
||||
// Estados locales para manejar la edición "en vivo" antes de persistir
|
||||
const [nombrePlan, setNombrePlan] = useState('')
|
||||
const [nivelPlan, setNivelPlan] = useState('')
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [nivelPlan, setNivelPlan] = useState<NivelPlanEstudio | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setNombrePlan(data.nombre || '')
|
||||
setNivelPlan(data.nivel || '')
|
||||
setNivelPlan(data.nivel)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const niveles = [
|
||||
const niveles: Array<NivelPlanEstudio> = [
|
||||
'Licenciatura',
|
||||
'Maestría',
|
||||
'Doctorado',
|
||||
'Diplomado',
|
||||
'Especialidad',
|
||||
'Diplomado',
|
||||
'Otro',
|
||||
]
|
||||
|
||||
const persistChange = (patch: any) => {
|
||||
mutate({ planId, patch })
|
||||
}
|
||||
|
||||
const MAX_CHARACTERS = 200
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
@@ -148,18 +152,18 @@ function RouteComponent() {
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
aria-label="Nombre del plan"
|
||||
title="Nombre del plan"
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={(e) => {
|
||||
const nuevoNombre =
|
||||
e.currentTarget.textContent?.trim() || ''
|
||||
const nuevoNombre = e.currentTarget.textContent.trim()
|
||||
setNombrePlan(nuevoNombre)
|
||||
if (nuevoNombre !== data?.nombre) {
|
||||
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"
|
||||
style={{ textDecoration: 'none' }}
|
||||
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"
|
||||
>
|
||||
{nombrePlan}
|
||||
</span>
|
||||
@@ -170,42 +174,68 @@ function RouteComponent() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge className="border-primary/20 bg-primary/10 text-primary hover:bg-primary/20 gap-1 px-3">
|
||||
{data?.estados_plan?.etiqueta}
|
||||
</Badge>
|
||||
</div>
|
||||
{(() => {
|
||||
const estadoColorHex = (data?.estados_plan as any)?.color as
|
||||
| string
|
||||
| undefined
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 3. Cards de Información */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<InfoCard
|
||||
icon={<GraduationCap className="text-muted-foreground" />}
|
||||
label="Nivel"
|
||||
<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">
|
||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<GraduationCap className="text-muted-foreground" />
|
||||
</div>
|
||||
<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}
|
||||
isEditable
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-48">
|
||||
{niveles.map((n) => (
|
||||
<DropdownMenuItem
|
||||
key={n}
|
||||
onClick={() => {
|
||||
setNivelPlan(n)
|
||||
if (n !== data?.nivel) {
|
||||
mutate({ planId, patch: { nivel: n } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
onValueChange={(value) => {
|
||||
const nuevoNivel = value as NivelPlanEstudio
|
||||
setNivelPlan(nuevoNivel)
|
||||
if (nuevoNivel !== data?.nivel) {
|
||||
mutate({ planId, patch: { nivel: nuevoNivel } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue placeholder="---" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{niveles.map((n) => (
|
||||
<SelectItem key={n} value={n}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoCard
|
||||
icon={<Clock className="text-muted-foreground" />}
|
||||
@@ -220,7 +250,7 @@ function RouteComponent() {
|
||||
<InfoCard
|
||||
icon={<CalendarDays className="text-muted-foreground" />}
|
||||
label="Creación"
|
||||
value={data?.creado_en?.split('T')[0]}
|
||||
value={data?.creado_en.split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
User,
|
||||
Loader2,
|
||||
Clock,
|
||||
Eye,
|
||||
History,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
@@ -156,7 +155,16 @@ function RouteComponent() {
|
||||
</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">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
||||
@@ -186,15 +194,6 @@ function RouteComponent() {
|
||||
</span>
|
||||
</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) */}
|
||||
<span className="text-muted-foreground/70 hidden text-[11px] lg:block">
|
||||
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useMemo, useState, useEffect, Fragment, useRef } from 'react'
|
||||
|
||||
import type { TipoAsignatura } from '@/data'
|
||||
import type { Asignatura } from '@/types/plan'
|
||||
import type { TablesUpdate } from '@/types/supabase'
|
||||
|
||||
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -402,15 +401,19 @@ function MapaCurricularPage() {
|
||||
asignatura: Asignatura,
|
||||
nuevoCiclo: number,
|
||||
) => {
|
||||
// 1. Esperamos la interacción del usuario
|
||||
const acepto = await validarConInterrupcion(asignatura.id, nuevoCiclo)
|
||||
|
||||
if (!acepto) {
|
||||
setConfirmState(null)
|
||||
return
|
||||
// 2. Limpiamos el estado del modal ANTES de seguir
|
||||
setConfirmState(null)
|
||||
|
||||
// 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 }
|
||||
|
||||
updateAsignatura(
|
||||
{ asignaturaId: asignatura.id, patch: patch as any },
|
||||
{
|
||||
@@ -420,12 +423,9 @@ function MapaCurricularPage() {
|
||||
m.id === asignatura.id ? { ...m, ciclo: nuevoCiclo } : m,
|
||||
),
|
||||
)
|
||||
setConfirmState(null) // Cerramos el modal de Radix al tener éxito
|
||||
},
|
||||
onError: (err) => {
|
||||
// En lugar de alert nativo, podrías usar un toast aquí
|
||||
console.error('Error al actualizar:', err)
|
||||
setConfirmState(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -587,48 +587,61 @@ function MapaCurricularPage() {
|
||||
|
||||
return val
|
||||
}
|
||||
const handleSaveChanges = () => {
|
||||
if (!editingData) return
|
||||
setAsignaturas((prev) =>
|
||||
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
||||
)
|
||||
type AsignaturaPatch = {
|
||||
codigo?: TablesUpdate<'asignaturas'>['codigo']
|
||||
nombre?: TablesUpdate<'asignaturas'>['nombre']
|
||||
tipo?: TablesUpdate<'asignaturas'>['tipo']
|
||||
creditos?: TablesUpdate<'asignaturas'>['creditos']
|
||||
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas']
|
||||
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
||||
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
||||
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
||||
prerrequisito_asignatura_id?: string | null
|
||||
|
||||
const procesarCambioAsignatura = async (
|
||||
asignaturaId: string,
|
||||
nuevosDatos: Partial<Asignatura>,
|
||||
) => {
|
||||
const asignaturaOriginal = asignaturas.find((a) => a.id === asignaturaId)
|
||||
if (!asignaturaOriginal) return
|
||||
|
||||
// ¿Cambió el ciclo? Si es así, validamos seriación
|
||||
if (
|
||||
nuevosDatos.ciclo !== undefined &&
|
||||
nuevosDatos.ciclo !== asignaturaOriginal.ciclo
|
||||
) {
|
||||
const acepto = await validarConInterrupcion(
|
||||
asignaturaId,
|
||||
nuevosDatos.ciclo,
|
||||
)
|
||||
setConfirmState(null)
|
||||
if (!acepto) return // El usuario canceló, no guardamos nada
|
||||
}
|
||||
const patch: Partial<AsignaturaPatch> = {
|
||||
nombre: editingData.nombre,
|
||||
codigo: editingData.clave,
|
||||
creditos: editingData.creditos,
|
||||
horas_academicas: editingData.hd,
|
||||
horas_independientes: editingData.hi,
|
||||
numero_ciclo: editingData.ciclo,
|
||||
linea_plan_id: editingData.lineaCurricularId,
|
||||
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
|
||||
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
||||
|
||||
// Si llegamos aquí, o no cambió el ciclo o el usuario aceptó el conflicto
|
||||
const patch = {
|
||||
nombre: nuevosDatos.nombre ?? asignaturaOriginal.nombre,
|
||||
codigo: nuevosDatos.clave ?? asignaturaOriginal.clave,
|
||||
numero_ciclo: nuevosDatos.ciclo,
|
||||
linea_plan_id: nuevosDatos.lineaCurricularId,
|
||||
creditos: nuevosDatos.creditos,
|
||||
horas_academicas: nuevosDatos.hd,
|
||||
horas_independientes: nuevosDatos.hi,
|
||||
prerrequisito_asignatura_id: nuevosDatos.prerrequisito_asignatura_id,
|
||||
tipo: nuevosDatos.tipo?.toUpperCase() as TipoAsignatura,
|
||||
}
|
||||
|
||||
updateAsignatura(
|
||||
{ asignaturaId: editingData.id, patch: patch as any },
|
||||
{ asignaturaId, patch: patch as any },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsEditModalOpen(false)
|
||||
// Opcional: Mostrar un toast de éxito
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error al guardar:', error)
|
||||
alert('Hubo un error al guardar los cambios.')
|
||||
setAsignaturas((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === asignaturaId ? { ...m, ...nuevosDatos } : m,
|
||||
),
|
||||
)
|
||||
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(
|
||||
(m) => m.ciclo === null || m.lineaCurricularId === null,
|
||||
)
|
||||
@@ -700,41 +713,13 @@ function MapaCurricularPage() {
|
||||
lineaId: string | null,
|
||||
) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!draggedAsignatura) return
|
||||
|
||||
// Buscamos la asignatura completa para tener sus datos
|
||||
const asignatura = asignaturas.find((a) => a.id === draggedAsignatura)
|
||||
if (!asignatura) return
|
||||
|
||||
// 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) },
|
||||
)
|
||||
}
|
||||
// Solo disparamos la lógica si realmente hay un cambio de posición
|
||||
procesarCambioAsignatura(draggedAsignatura, {
|
||||
ciclo: cicloDestino,
|
||||
lineaCurricularId: lineaId,
|
||||
})
|
||||
|
||||
setDraggedAsignatura(null)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,22 @@ import {
|
||||
useParams,
|
||||
useRouterState,
|
||||
} 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 { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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(
|
||||
'/planes/$planId/asignaturas/$asignaturaId',
|
||||
@@ -19,65 +29,186 @@ export const Route = createFileRoute(
|
||||
component: AsignaturaLayout,
|
||||
})
|
||||
|
||||
function EditableHeaderField({
|
||||
// --- 1. COMPONENTE PARA EDITAR EL TÍTULO SOBRE FONDO AZUL ---
|
||||
function InlineEditTitle({
|
||||
value,
|
||||
onSave,
|
||||
className,
|
||||
}: {
|
||||
value: string | number
|
||||
value: string
|
||||
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)
|
||||
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
|
||||
const newValue = e.currentTarget.innerText
|
||||
if (newValue !== textValue) {
|
||||
onSave(newValue)
|
||||
}
|
||||
useEffect(() => setTempVal(value), [value])
|
||||
|
||||
const handleSave = () => {
|
||||
setIsEditing(false)
|
||||
if (tempVal.trim() && tempVal !== value) onSave(tempVal.trim())
|
||||
else setTempVal(value) // Revertir si está vacío
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
|
||||
}
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
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 (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
|
||||
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 ?? ''} `}
|
||||
<h1
|
||||
onClick={() => setIsEditing(true)}
|
||||
// Texto blanco por defecto, fondo blanco sutil al hover
|
||||
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"
|
||||
>
|
||||
{textValue}
|
||||
</span>
|
||||
{value}
|
||||
{/* 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 {
|
||||
nombre?: string
|
||||
}
|
||||
|
||||
function AsignaturaLayout() {
|
||||
const location = useLocation()
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { planId } = useParams({
|
||||
const { asignaturaId, planId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||
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()
|
||||
|
||||
// Dentro de AsignaturaDetailPage
|
||||
const [headerData, setHeaderData] = useState({
|
||||
codigo: '',
|
||||
nombre: '',
|
||||
@@ -85,7 +216,6 @@ function AsignaturaLayout() {
|
||||
ciclo: 0,
|
||||
})
|
||||
|
||||
// Sincronizar cuando llegue la API
|
||||
useEffect(() => {
|
||||
if (asignaturaApi) {
|
||||
setHeaderData({
|
||||
@@ -97,28 +227,38 @@ function AsignaturaLayout() {
|
||||
}
|
||||
}, [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 }
|
||||
setHeaderData(newData)
|
||||
|
||||
const patch: Record<string, any> =
|
||||
key === 'ciclo'
|
||||
? { numero_ciclo: value }
|
||||
: {
|
||||
[key]: value,
|
||||
}
|
||||
key === 'ciclo' ? { numero_ciclo: value } : { [key]: value }
|
||||
|
||||
updateAsignatura.mutate({
|
||||
asignaturaId,
|
||||
patch,
|
||||
})
|
||||
updateAsignatura.mutate({ asignaturaId, patch })
|
||||
}
|
||||
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
|
||||
// Confetti al llegar desde creación IA
|
||||
useEffect(() => {
|
||||
if ((location.state as any)?.showConfetti) {
|
||||
lateralConfetti()
|
||||
@@ -128,91 +268,110 @@ function AsignaturaLayout() {
|
||||
|
||||
if (loadingAsig) {
|
||||
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...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Si no hay datos y no está cargando, algo falló
|
||||
if (!asignaturaApi) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="mx-auto p-4 py-10 md:px-6 lg:px-8">
|
||||
<div className="bg-background min-h-screen">
|
||||
{/* HEADER DE LA ASIGNATURA CON TU FONDO AZUL HARDCODEADO */}
|
||||
<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
|
||||
to="/planes/$planId/asignaturas"
|
||||
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
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
{/* CÓDIGO EDITABLE */}
|
||||
<Badge className="border border-blue-700 bg-blue-900/50">
|
||||
<EditableHeaderField
|
||||
value={headerData.codigo}
|
||||
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 className="flex flex-col gap-4">
|
||||
{/* Título Editable (Texto blanco controlado dentro del componente) */}
|
||||
<div className="-ml-2">
|
||||
<InlineEditTitle
|
||||
value={headerData.nombre}
|
||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
{/* CRÉDITOS EDITABLES */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<span className="inline-flex max-w-fit">
|
||||
<EditableHeaderField
|
||||
value={headerData.creditos}
|
||||
onSave={(val) =>
|
||||
handleUpdateHeader('creditos', parseInt(val) || 0)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span>créditos</span>
|
||||
{/* Fila de Metadatos Alineados */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Badge Estático del Tipo (Estilo oscuro sutil) */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex h-8 cursor-default items-center gap-1.5 border-white/10 bg-white/5 px-3 text-white"
|
||||
>
|
||||
<Tag size={12} className="text-white/70" />
|
||||
{asignaturaApi.tipo}
|
||||
</Badge>
|
||||
|
||||
{/* SEMESTRE EDITABLE */}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<EditableHeaderField
|
||||
value={headerData.ciclo}
|
||||
onSave={(val) =>
|
||||
handleUpdateHeader('ciclo', parseInt(val) || 0)
|
||||
}
|
||||
/>
|
||||
<span>° ciclo</span>
|
||||
</Badge>
|
||||
{/* Badges Editables (Texto blanco controlado dentro de los componentes) */}
|
||||
<InlineEditBadge
|
||||
icon={<Hash size={14} />}
|
||||
label="Código"
|
||||
value={headerData.codigo}
|
||||
onSave={(val) => handleUpdateHeader('codigo', val)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</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 */}
|
||||
|
||||
<nav className="sticky top-0 z-20 border-b bg-white">
|
||||
@@ -246,11 +405,12 @@ function AsignaturaLayout() {
|
||||
}
|
||||
from="/planes/$planId/asignaturas/$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
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||
}`}
|
||||
? 'border-primary text-primary font-bold'
|
||||
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
|
||||
@@ -194,9 +194,7 @@ function RouteComponent() {
|
||||
variant="secondary"
|
||||
onClick={resetFilters}
|
||||
disabled={isClearDisabled}
|
||||
className={`shadow-md ${
|
||||
isClearDisabled ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
className={`shadow-md`}
|
||||
>
|
||||
<Icons.X className="h-4 w-4" /> Limpiar
|
||||
</Button>
|
||||
@@ -224,9 +222,9 @@ function RouteComponent() {
|
||||
// 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'
|
||||
// Aquí uso un fallback simple.
|
||||
const estadoColor = estado?.es_final
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-amber-600'
|
||||
const estadoColorHex = (estado as any)?.color as
|
||||
| string
|
||||
| undefined
|
||||
|
||||
return (
|
||||
<PlanEstudiosCard
|
||||
@@ -237,7 +235,8 @@ function RouteComponent() {
|
||||
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
|
||||
facultad={facultad?.nombre ?? 'Sin Facultad'}
|
||||
estado={estado?.etiqueta ?? 'Desconocido'}
|
||||
claseColorEstado={estadoColor}
|
||||
colorEstadoHex={estadoColorHex}
|
||||
claseColorEstado={!estadoColorHex ? 'bg-secondary' : ''}
|
||||
colorFacultad={facultad?.color ?? '#000000'}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
|
||||
@@ -346,3 +346,15 @@ body {
|
||||
.login-bg {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user