wip
This commit is contained in:
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,21 @@ 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 { 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 } 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 +28,139 @@ export const Route = createFileRoute(
|
|||||||
component: AsignaturaLayout,
|
component: AsignaturaLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
function EditableHeaderField({
|
// --- 1. COMPONENTE PARA EDITAR EL TÍTULO (h1) ---
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-background text-foreground border-primary focus:ring-primary/20 w-full rounded-md border-2 px-2 py-1 text-3xl font-bold 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
|
className="group text-foreground hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 text-3xl font-bold transition-colors"
|
||||||
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 ?? ''} `}
|
|
||||||
>
|
>
|
||||||
{textValue}
|
{value}
|
||||||
</span>
|
<Pencil className="text-muted-foreground hover:text-primary h-5 w-5 opacity-0 transition-all group-hover:opacity-100" />
|
||||||
|
</h1>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 2. COMPONENTE PARA EDITAR LOS BADGES (Código, Créditos, Semestre) ---
|
||||||
|
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 (
|
||||||
|
<div className="bg-background border-primary ring-primary/20 flex h-8 items-center gap-2 rounded-md border px-3 shadow-sm ring-2">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium tracking-wider 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)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-foreground w-16 bg-transparent text-sm font-semibold outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="border-border bg-muted/30 hover:border-primary/40 hover:bg-muted group flex h-8 items-center gap-2 rounded-md border px-3 text-sm transition-all"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<span className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||||
|
{label}:
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-semibold">
|
||||||
|
{value} {suffix}
|
||||||
|
</span>
|
||||||
|
<Pencil className="text-muted-foreground h-3 w-3 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 updateAsignatura = useUpdateAsignatura()
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
// Dentro de AsignaturaDetailPage
|
|
||||||
const [headerData, setHeaderData] = useState({
|
const [headerData, setHeaderData] = useState({
|
||||||
codigo: '',
|
codigo: '',
|
||||||
nombre: '',
|
nombre: '',
|
||||||
@@ -85,7 +168,6 @@ function AsignaturaLayout() {
|
|||||||
ciclo: 0,
|
ciclo: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sincronizar cuando llegue la API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturaApi) {
|
if (asignaturaApi) {
|
||||||
setHeaderData({
|
setHeaderData({
|
||||||
@@ -102,23 +184,15 @@ function AsignaturaLayout() {
|
|||||||
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,101 +202,93 @@ 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 */}
|
||||||
<div className="mx-auto p-4 py-10 md:px-6 lg:px-8">
|
<section className="bg-card border-border border-b 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"
|
className="text-muted-foreground hover:text-foreground mb-4 flex w-fit items-center gap-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
<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 */}
|
||||||
{/* 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 */}
|
||||||
<span className="inline-flex max-w-fit">
|
<Badge
|
||||||
<EditableHeaderField
|
variant="secondary"
|
||||||
value={headerData.creditos}
|
className="flex h-8 items-center gap-1.5 px-3"
|
||||||
onSave={(val) =>
|
>
|
||||||
handleUpdateHeader('creditos', parseInt(val) || 0)
|
<Tag size={12} className="opacity-70" />
|
||||||
}
|
{asignaturaApi.tipo}
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>créditos</span>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* SEMESTRE EDITABLE */}
|
{/* Badges Editables */}
|
||||||
<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 */}
|
||||||
|
<div className="text-muted-foreground mt-2 flex items-center gap-2 text-sm">
|
||||||
|
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||||
|
<span>Pertenece al plan:</span>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* TABS */}
|
{/* TABS NAVEGACIÓN */}
|
||||||
|
<nav className="bg-background/80 border-border sticky top-0 z-20 border-b backdrop-blur-md">
|
||||||
<nav className="sticky top-0 z-20 border-b bg-white">
|
<div className="mx-auto px-4 py-1 md:px-6 lg:px-8">
|
||||||
<div className="mx-auto p-4 py-2 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">
|
||||||
{/* CAMBIOS CLAVE:
|
|
||||||
1. overflow-x-auto: Permite scroll horizontal.
|
|
||||||
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
|
|
||||||
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
|
|
||||||
*/}
|
|
||||||
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
|
|
||||||
{[
|
{[
|
||||||
{ label: 'Datos', to: '' },
|
{ label: 'Datos', to: '' },
|
||||||
{ label: 'Contenido', to: 'contenido' },
|
{ label: 'Contenido', to: 'contenido' },
|
||||||
@@ -246,11 +312,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>
|
||||||
|
|||||||
@@ -224,9 +224,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 +237,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({
|
||||||
|
|||||||
Reference in New Issue
Block a user