Compare commits
58 Commits
4c730fa0ab
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c744b8c89 | |||
| 18ba592378 | |||
| 1ede5bca32 | |||
| 80cc0a9f2b | |||
| 7b437c56e0 | |||
| b37d36fe15 | |||
| d3267e5890 | |||
| b77906e2e9 | |||
| 03cdd24c1d | |||
| a98982d228 | |||
| c710b85090 | |||
| 36b9c559fa | |||
| 434b50cfc3 | |||
| 51f967a955 | |||
| f5a4b0b9af | |||
| 8ae3469e10 | |||
| ddb83782a6 | |||
| 085e6fa0e9 | |||
| b3e9d63833 | |||
| 7a2f16b160 | |||
| 54200986ab | |||
| bfafb8999e | |||
| 883e6e7fa4 | |||
| dd11c2707e | |||
| 60c2e8402d | |||
| 350d7c0f69 | |||
| f2900f3d22 | |||
| 1f27ea6252 | |||
| 0e710f9637 | |||
| 3bb3a11779 | |||
| 75784658bf | |||
| d2f12d96de | |||
| 7f9c3162a0 | |||
| 03ef51d8a6 | |||
| f81b404223 | |||
| 614150f8d1 | |||
| 54a3b74836 | |||
| 0820bf19a5 | |||
| 7ddc5c7f1f | |||
| ad7a741d1e | |||
| 60bfa31186 | |||
| 1bd37c7e27 | |||
| 7eb9ca0bec | |||
| 10dc299311 | |||
| b3954ab16c | |||
| 88a2a28a8d | |||
| 36a11e3793 | |||
| 33efaed03f | |||
| d481e9706c | |||
| ee3b7a56ec | |||
| 2359e38f85 | |||
| ed318fa67b | |||
| c262cd16be | |||
| a08b2abf87 | |||
| a07213d959 | |||
| a2234e5022 | |||
| 658c392f96 | |||
| 9fd816bfa1 |
@@ -41,7 +41,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
@@ -1327,7 +1327,7 @@
|
|||||||
|
|
||||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
@@ -7,6 +7,13 @@ import type { AsignaturaDetail } from '@/data'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -14,6 +21,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import { usePlanAsignaturas } from '@/data'
|
||||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||||
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
||||||
|
|
||||||
@@ -64,8 +72,12 @@ export default function AsignaturaDetailPage() {
|
|||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
|
const { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||||
|
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||||
|
usePlanAsignaturas(planId)
|
||||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
@@ -86,16 +98,52 @@ export default function AsignaturaDetailPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asignaturaSeriada = useMemo(() => {
|
||||||
|
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
|
||||||
|
return null
|
||||||
|
return asignaturasApi.find(
|
||||||
|
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
|
||||||
|
)
|
||||||
|
}, [asignaturaApi, asignaturasApi])
|
||||||
|
const requisitosFormateados = useMemo(() => {
|
||||||
|
if (!asignaturaSeriada) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: asignaturaSeriada.codigo,
|
||||||
|
name: asignaturaSeriada.nombre,
|
||||||
|
id: asignaturaSeriada.id, // Guardamos el ID para el select
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [asignaturaSeriada])
|
||||||
|
|
||||||
|
const handleUpdatePrerrequisito = (newId: string | null) => {
|
||||||
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch: {
|
||||||
|
prerrequisito_asignatura_id: newId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
/* ---------- sincronizar API ---------- */
|
/* ---------- sincronizar API ---------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
}, [asignaturaApi])
|
}, [asignaturaApi, requisitosFormateados])
|
||||||
|
|
||||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
return (
|
||||||
|
<DatosGenerales
|
||||||
|
pre={requisitosFormateados}
|
||||||
|
availableSubjects={asignaturasApi}
|
||||||
|
onPersistDato={handlePersistDatoGeneral}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatosGenerales({
|
function DatosGenerales({
|
||||||
onPersistDato,
|
onPersistDato,
|
||||||
|
pre,
|
||||||
|
availableSubjects,
|
||||||
}: {
|
}: {
|
||||||
onPersistDato: (clave: string, value: string) => void
|
onPersistDato: (clave: string, value: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -132,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 []
|
||||||
|
|
||||||
@@ -268,24 +315,32 @@ function DatosGenerales({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tarjeta de Requisitos */}
|
{/* Tarjeta de Requisitos */}
|
||||||
<InfoCard
|
<InfoCard
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
title="Requisitos y Seriación"
|
title="Requisitos y Seriación"
|
||||||
type="requirements"
|
type="requirements"
|
||||||
initialContent={[
|
initialContent={pre}
|
||||||
{
|
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
||||||
type: 'Pre-requisito',
|
availableSubjects={
|
||||||
code: 'PA-301',
|
availableSubjects?.filter(
|
||||||
name: 'Programación Avanzada',
|
(a) =>
|
||||||
},
|
a.id !== asignaturaId &&
|
||||||
{
|
a.numero_ciclo < data?.numero_ciclo &&
|
||||||
type: 'Co-requisito',
|
a.numero_ciclo,
|
||||||
code: 'MAT-201',
|
) || []
|
||||||
name: 'Matemáticas Discretas',
|
}
|
||||||
},
|
onPersist={({ value }) => {
|
||||||
]}
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch: {
|
||||||
|
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tarjeta de Evaluación */}
|
{/* Tarjeta de Evaluación */}
|
||||||
<InfoCard
|
<InfoCard
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
title="Sistema de Evaluación"
|
title="Sistema de Evaluación"
|
||||||
type="evaluation"
|
type="evaluation"
|
||||||
initialContent={criteriosEvaluacion}
|
initialContent={criteriosEvaluacion}
|
||||||
@@ -321,6 +376,7 @@ interface InfoCardProps {
|
|||||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
containerRef?: React.RefObject<HTMLDivElement | null>
|
||||||
forceEditToken?: number
|
forceEditToken?: number
|
||||||
highlightToken?: number
|
highlightToken?: number
|
||||||
|
availableSubjects?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
@@ -337,6 +393,7 @@ function InfoCard({
|
|||||||
containerRef,
|
containerRef,
|
||||||
forceEditToken,
|
forceEditToken,
|
||||||
highlightToken,
|
highlightToken,
|
||||||
|
availableSubjects,
|
||||||
}: InfoCardProps) {
|
}: InfoCardProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
@@ -396,8 +453,6 @@ function InfoCard({
|
|||||||
}, [highlightToken])
|
}, [highlightToken])
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
console.log('clave, valor:', clave, String(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) {
|
||||||
@@ -427,6 +482,23 @@ function InfoCard({
|
|||||||
void onPersist?.({ type, clave, value: cleaned })
|
void onPersist?.({ type, clave, value: cleaned })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (type === 'requirements') {
|
||||||
|
// 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 =
|
||||||
|
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
|
||||||
|
|
||||||
|
setData(tempText) // Actualiza la vista local
|
||||||
|
setIsEditing(false)
|
||||||
|
|
||||||
|
// Mandamos el ID específico a la base de datos
|
||||||
|
void onPersist?.({
|
||||||
|
type,
|
||||||
|
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
|
||||||
|
value: prerequisiteId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setData(tempText)
|
setData(tempText)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
@@ -437,16 +509,21 @@ function InfoCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (campoClave: string) => {
|
const handleIARequest = (campoClave: string) => {
|
||||||
console.log(placeholder)
|
let targetClave = campoClave
|
||||||
|
if (type === 'evaluation' && !targetClave) {
|
||||||
|
targetClave = 'criterios_de_evaluacion'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetClave === 'contenido') {
|
||||||
|
targetClave = 'contenido_tematico'
|
||||||
|
}
|
||||||
|
|
||||||
// Añadimos un timestamp a la state para forzar que la navegación
|
|
||||||
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
|
||||||
navigate({
|
navigate({
|
||||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||||
params: { planId, asignaturaId: asignaturaId! },
|
params: { planId, asignaturaId: asignaturaId! },
|
||||||
state: {
|
state: {
|
||||||
activeTab: 'ia',
|
activeTab: 'ia',
|
||||||
prefillCampo: campoClave,
|
prefillCampo: targetClave,
|
||||||
prefillContenido: data,
|
prefillContenido: data,
|
||||||
_ts: Date.now(),
|
_ts: Date.now(),
|
||||||
} as any,
|
} as any,
|
||||||
@@ -501,19 +578,21 @@ function InfoCard({
|
|||||||
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Tooltip>
|
{type !== 'requirements' && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
size="icon"
|
||||||
onClick={() => clave && handleIARequest(clave)}
|
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||||
>
|
onClick={() => handleIARequest(clave)}
|
||||||
<Sparkles className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Sparkles className="h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent>Mejorar con IA</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent>Mejorar con IA</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -546,7 +625,69 @@ function InfoCard({
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{type === 'evaluation' ? (
|
{/* Condicionales de edición según el tipo */}
|
||||||
|
{type === 'requirements' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-medium text-slate-500">
|
||||||
|
Materia de Seriación
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
Array.isArray(tempText) && tempText.length > 0
|
||||||
|
? tempText[0].id
|
||||||
|
: 'none'
|
||||||
|
}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const selected = availableSubjects?.find(
|
||||||
|
(s) => s.id === val,
|
||||||
|
)
|
||||||
|
if (val === 'none' || !selected) {
|
||||||
|
setTempText([])
|
||||||
|
} else {
|
||||||
|
setTempText([
|
||||||
|
{
|
||||||
|
id: selected.id,
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: selected.codigo,
|
||||||
|
name: selected.nombre,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<div className="flex-1 truncate text-left">
|
||||||
|
<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="text-primary font-bold">
|
||||||
|
[C{asig.numero_ciclo}]
|
||||||
|
</span>{' '}
|
||||||
|
<span className="inline-block truncate">
|
||||||
|
{asig.codigo} - {asig.nombre}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : type === 'evaluation' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{evalRows.map((row) => (
|
{evalRows.map((row) => (
|
||||||
@@ -568,85 +709,36 @@ function InfoCard({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={row.porcentaje}
|
value={row.porcentaje}
|
||||||
placeholder="%"
|
placeholder="%"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
// Solo permitir '' o dígitos
|
|
||||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||||
|
|
||||||
if (raw === '') {
|
|
||||||
setEvalRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.id === row.id
|
|
||||||
? {
|
|
||||||
id: r.id,
|
|
||||||
criterio: r.criterio,
|
|
||||||
porcentaje: '',
|
|
||||||
}
|
|
||||||
: r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const n = Number(raw)
|
|
||||||
if (!Number.isFinite(n)) return
|
|
||||||
const porcentaje = Math.trunc(n)
|
|
||||||
if (porcentaje < 1 || porcentaje > 100) return
|
|
||||||
|
|
||||||
// No permitir suma > 100
|
|
||||||
setEvalRows((prev) => {
|
setEvalRows((prev) => {
|
||||||
const next = prev.map((r) =>
|
const next = prev.map((r) =>
|
||||||
r.id === row.id
|
r.id === row.id ? { ...r, porcentaje: raw } : r,
|
||||||
? {
|
)
|
||||||
id: r.id,
|
const total = next.reduce(
|
||||||
criterio: r.criterio,
|
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
||||||
porcentaje: raw,
|
0,
|
||||||
}
|
|
||||||
: r,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const total = next.reduce((acc, r) => {
|
|
||||||
const v = String(r.porcentaje).trim()
|
|
||||||
if (!v) return acc
|
|
||||||
const nn = Number(v)
|
|
||||||
if (!Number.isFinite(nn)) return acc
|
|
||||||
const vv = Math.trunc(nn)
|
|
||||||
if (vv < 1 || vv > 100) return acc
|
|
||||||
return acc + vv
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
return total > 100 ? prev : next
|
return total > 100 ? prev : next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="text-sm text-slate-600">%</div>
|
||||||
<div
|
|
||||||
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
setEvalRows((prev) =>
|
setEvalRows((prev) =>
|
||||||
prev.filter((r) => r.id !== row.id),
|
prev.filter((r) => r.id !== row.id),
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
aria-label="Quitar renglón"
|
|
||||||
title="Quitar"
|
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -656,22 +748,15 @@ function InfoCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={
|
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
||||||
'text-sm ' +
|
|
||||||
(evaluationTotal === 100
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: 'text-destructive font-semibold')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Total: {evaluationTotal}/100
|
Total: {evaluationTotal}/100
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-emerald-700 hover:bg-emerald-50"
|
className="text-emerald-700 hover:bg-emerald-50"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
// Agregar una fila vacía (siempre permitido)
|
|
||||||
setEvalRows((prev) => [
|
setEvalRows((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -680,7 +765,7 @@ function InfoCard({
|
|||||||
porcentaje: '',
|
porcentaje: '',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||||
</Button>
|
</Button>
|
||||||
@@ -694,28 +779,15 @@ function InfoCard({
|
|||||||
className="min-h-30 text-sm leading-relaxed"
|
className="min-h-30 text-sm leading-relaxed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Botones de acción comunes */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
if (type === 'evaluation') {
|
// Lógica de reset si es necesario...
|
||||||
const raw = Array.isArray(data) ? data : []
|
|
||||||
setEvalRows(
|
|
||||||
raw.map((r: CriterioEvaluacionRow) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
criterio:
|
|
||||||
typeof r.criterio === 'string' ? r.criterio : '',
|
|
||||||
porcentaje:
|
|
||||||
typeof r.porcentaje === 'number'
|
|
||||||
? String(Math.trunc(r.porcentaje))
|
|
||||||
: typeof r.porcentaje === 'string'
|
|
||||||
? String(Math.trunc(Number(r.porcentaje)))
|
|
||||||
: '',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
@@ -731,6 +803,7 @@ function InfoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* Modo Visualización */
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
{type === 'text' &&
|
{type === 'text' &&
|
||||||
(data ? (
|
(data ? (
|
||||||
@@ -739,9 +812,7 @@ function InfoCard({
|
|||||||
<p className="text-slate-400 italic">Sin información.</p>
|
<p className="text-slate-400 italic">Sin información.</p>
|
||||||
))}
|
))}
|
||||||
{type === 'requirements' && <RequirementsView items={data} />}
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
{type === 'evaluation' && (
|
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||||
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ export function BibliographyItem() {
|
|||||||
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
<div className="animate-in fade-in space-y-8 pb-8 duration-500">
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
Bibliografía
|
Bibliografía
|
||||||
@@ -169,7 +169,7 @@ export function BibliographyItem() {
|
|||||||
resetScroll: false,
|
resetScroll: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
className="shadow-md"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
|
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export function ContenidoTematico() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500">
|
<div className="animate-in fade-in space-y-6 pb-8 duration-500">
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
|
|||||||
@@ -94,11 +94,7 @@ export function DocumentoSEPTab({
|
|||||||
|
|
||||||
{pdfUrl && !isLoading && (
|
{pdfUrl && !isLoading && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button size="sm" className="gap-2" onClick={onDownloadWord}>
|
||||||
size="sm"
|
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
|
||||||
onClick={onDownloadWord}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Descargar Word
|
<Download className="h-4 w-4" /> Descargar Word
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ interface ImprovementCardProps {
|
|||||||
sug: IASugerencia
|
sug: IASugerencia
|
||||||
asignaturaId: string
|
asignaturaId: string
|
||||||
onApplied: (campoKey: string) => void
|
onApplied: (campoKey: string) => void
|
||||||
|
isSelected?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImprovementCard({
|
export function ImprovementCard({
|
||||||
@@ -87,19 +88,19 @@ export function ImprovementCard({
|
|||||||
{valor.map((u: any, idx: number) => (
|
{valor.map((u: any, idx: number) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
|
className="bg-card border-primary/20 rounded-md border p-2 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
|
<div className="border-border/50 text-primary mb-1 flex items-center gap-2 border-b pb-1 text-[11px] font-bold">
|
||||||
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
|
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{u.temas?.map((t: any, tidx: number) => (
|
{u.temas?.map((t: any, tidx: number) => (
|
||||||
<li
|
<li
|
||||||
key={tidx}
|
key={tidx}
|
||||||
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
|
className="text-muted-foreground flex items-start justify-between gap-2 text-[10px]"
|
||||||
>
|
>
|
||||||
<span className="leading-tight">• {t.nombre}</span>
|
<span className="leading-tight">• {t.nombre}</span>
|
||||||
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
|
<span className="text-muted-foreground/70 flex shrink-0 items-center gap-0.5 font-mono">
|
||||||
<Clock size={10} /> {t.horasEstimadas}h
|
<Clock size={10} /> {t.horasEstimadas}h
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -115,24 +116,24 @@ export function ImprovementCard({
|
|||||||
if (valor[0]?.hasOwnProperty('criterio')) {
|
if (valor[0]?.hasOwnProperty('criterio')) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
|
<div className="text-muted-foreground/70 mb-1 flex items-center gap-2 text-[10px] font-bold uppercase">
|
||||||
<ListChecks size={12} /> Desglose de evaluación
|
<ListChecks size={12} /> Desglose de evaluación
|
||||||
</div>
|
</div>
|
||||||
{valor.map((c: any, idx: number) => (
|
{valor.map((c: any, idx: number) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
|
className="bg-card border-border flex items-center justify-between gap-3 rounded-md border p-2 shadow-sm"
|
||||||
>
|
>
|
||||||
<span className="text-[11px] leading-tight text-slate-700">
|
<span className="text-foreground text-[11px] leading-tight">
|
||||||
{c.criterio}
|
{c.criterio}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
<div className="border-accent/30 bg-accent/10 text-accent flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold">
|
||||||
{c.porcentaje}%
|
{c.porcentaje}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Opcional: Suma total para verificar que de 100% */}
|
{/* Opcional: Suma total para verificar que de 100% */}
|
||||||
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
|
<div className="text-muted-foreground/70 pt-1 text-right text-[9px] font-medium">
|
||||||
Total:{' '}
|
Total:{' '}
|
||||||
{valor.reduce(
|
{valor.reduce(
|
||||||
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
|
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
|
||||||
@@ -155,17 +156,17 @@ export function ImprovementCard({
|
|||||||
// --- ESTADO APLICADO ---
|
// --- ESTADO APLICADO ---
|
||||||
if (sug.aceptada) {
|
if (sug.aceptada) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
|
<div className="bg-card border-border flex flex-col rounded-xl border p-3 opacity-80 shadow-sm">
|
||||||
<div className="mb-3 flex items-center justify-between gap-4">
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
<span className="text-sm font-bold text-slate-800">
|
<span className="text-foreground text-sm font-bold">
|
||||||
{sug.campoNombre}
|
{sug.campoNombre}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
|
<div className="border-border bg-muted/50 text-muted-foreground flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium">
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
Aplicado
|
Aplicado
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
|
<div className="border-primary/20 bg-primary/5 text-muted-foreground rounded-lg border p-3 text-xs leading-relaxed">
|
||||||
{renderContenido(sug.valorSugerido)}
|
{renderContenido(sug.valorSugerido)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,16 +175,16 @@ export function ImprovementCard({
|
|||||||
|
|
||||||
// --- ESTADO PENDIENTE ---
|
// --- ESTADO PENDIENTE ---
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
|
<div className="bg-card border-primary/20 hover:border-primary/40 group flex flex-col rounded-xl border p-3 shadow-sm transition-all">
|
||||||
<div className="mb-3 flex items-center justify-between gap-4">
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
|
<span className="border-primary/20 bg-primary/10 text-primary max-w-[150px] truncate rounded-lg border px-2.5 py-1 text-[10px] font-bold tracking-wider uppercase">
|
||||||
{sug.campoNombre}
|
{sug.campoNombre}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isApplying || !asignatura}
|
disabled={isApplying || !asignatura}
|
||||||
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
|
className="h-8 w-auto px-4 text-xs font-semibold shadow-sm"
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
>
|
>
|
||||||
{isApplying ? (
|
{isApplying ? (
|
||||||
@@ -197,7 +198,7 @@ export function ImprovementCard({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
|
'border-border/60 bg-muted/30 text-muted-foreground rounded-lg border border-dashed p-3 text-xs leading-relaxed',
|
||||||
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onConfirm: () => void
|
||||||
|
titulo?: string
|
||||||
|
descripcion?: string
|
||||||
|
}
|
||||||
|
export const AlertaConflicto = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
titulo,
|
||||||
|
descripcion,
|
||||||
|
}: Props) => {
|
||||||
|
// Intentamos parsear el mensaje si viene como JSON para la lista de materias
|
||||||
|
let contenido
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(descripcion as any)
|
||||||
|
contenido = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-600">{data.main}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
{data.materias.map((m: string, i: number) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs font-semibold text-slate-500">
|
||||||
|
¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
|
||||||
|
seriación)?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
|
||||||
|
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
|
||||||
|
{titulo}
|
||||||
|
</AlertDialog.Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<Button variant="ghost">Cancelar</Button>
|
||||||
|
{/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Mover de todos modos
|
||||||
|
</Button>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
|
||||||
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useSubject } from '@/data'
|
||||||
|
|
||||||
|
export function PasoBasicosClonadoInterno({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||||
|
const { data: source, isLoading, isError } = useSubject(sourceId)
|
||||||
|
|
||||||
|
const lastAppliedRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!source) return
|
||||||
|
if (lastAppliedRef.current === source.id) return
|
||||||
|
|
||||||
|
lastAppliedRef.current = source.id
|
||||||
|
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
nombre: source.nombre,
|
||||||
|
codigo: source.codigo ?? '',
|
||||||
|
tipo: (source.tipo as any) ?? null,
|
||||||
|
creditos: source.creditos,
|
||||||
|
horasAcademicas: (source as any).horas_academicas ?? null,
|
||||||
|
horasIndependientes: (source as any).horas_independientes ?? null,
|
||||||
|
estructuraId: (source.estructura_id ??
|
||||||
|
w.datosBasicos.estructuraId) as any,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [onChange, source])
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Selecciona una asignatura fuente para continuar.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Cargando información de la asignatura fuente…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !source) {
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive flex items-center gap-2 text-base">
|
||||||
|
<Icons.AlertTriangle className="h-5 w-5" />
|
||||||
|
No se pudo cargar la fuente
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Intenta seleccionar otra asignatura.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PasoBasicosForm
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={onChange}
|
||||||
|
estructuraFuenteId={source.estructura_id ?? null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
||||||
@@ -21,9 +22,11 @@ import { cn } from '@/lib/utils'
|
|||||||
export function PasoBasicosForm({
|
export function PasoBasicosForm({
|
||||||
wizard,
|
wizard,
|
||||||
onChange,
|
onChange,
|
||||||
|
estructuraFuenteId,
|
||||||
}: {
|
}: {
|
||||||
wizard: NewSubjectWizardState
|
wizard: NewSubjectWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
estructuraFuenteId?: string | null
|
||||||
}) {
|
}) {
|
||||||
const { data: estructuras } = useSubjectEstructuras()
|
const { data: estructuras } = useSubjectEstructuras()
|
||||||
|
|
||||||
@@ -258,6 +261,17 @@ export function PasoBasicosForm({
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{estructuraFuenteId &&
|
||||||
|
wizard.datosBasicos.estructuraId &&
|
||||||
|
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
|
||||||
|
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
|
||||||
|
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
|
||||||
|
<span>
|
||||||
|
Es posible que se pierdan datos generales al seleccionar otra
|
||||||
|
estructura.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
import type { Database } from '@/types/supabase'
|
||||||
|
|
||||||
|
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type SourceSubjectRow = {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
codigo: string | null
|
||||||
|
creditos: number
|
||||||
|
tipo: any
|
||||||
|
plan_estudio_id: string
|
||||||
|
estructura_id: string | null
|
||||||
|
rank?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL = '__all__'
|
||||||
|
|
||||||
|
type SearchAsignaturasRow =
|
||||||
|
Database['public']['Functions']['search_asignaturas']['Returns'][number]
|
||||||
|
|
||||||
|
export function PasoFuenteClonadoInterno({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const facultadId = wizard.clonInterno?.facultadId ?? null
|
||||||
|
const carreraId = wizard.clonInterno?.carreraId ?? null
|
||||||
|
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
|
||||||
|
const search = wizard.clonInterno?.search ?? ''
|
||||||
|
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebounce(search, 350)
|
||||||
|
|
||||||
|
const { data: catalogos } = useCatalogosPlanes()
|
||||||
|
|
||||||
|
const carrerasOptions = useMemo(() => {
|
||||||
|
const raw = catalogos?.carreras ?? []
|
||||||
|
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
|
||||||
|
}, [catalogos?.carreras, facultadId])
|
||||||
|
|
||||||
|
const planesQuery = usePlanes({
|
||||||
|
search: '',
|
||||||
|
facultadId: facultadId ?? 'todas',
|
||||||
|
carreraId: carreraId ?? 'todas',
|
||||||
|
estadoId: 'todos',
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
|
||||||
|
const plansForFilter = planesQuery.data?.data ?? []
|
||||||
|
|
||||||
|
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'asignaturas',
|
||||||
|
'clone-source',
|
||||||
|
{
|
||||||
|
facultadId,
|
||||||
|
carreraId,
|
||||||
|
planOrigenId,
|
||||||
|
search: debouncedSearch,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
planIdsKey: needPlansForFilter
|
||||||
|
? plansForFilter.map((p) => p.id).join(',')
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
enabled: !needPlansForFilter || !planesQuery.isLoading,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const from = (page - 1) * pageSize
|
||||||
|
const term = debouncedSearch.trim()
|
||||||
|
|
||||||
|
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
|
||||||
|
const { data, error } = await supabase.rpc('search_asignaturas', {
|
||||||
|
p_search: term,
|
||||||
|
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
|
||||||
|
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
|
||||||
|
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
|
||||||
|
p_limit: pageSize,
|
||||||
|
p_offset: from,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
|
||||||
|
// Extraemos el conteo total de la primera fila.
|
||||||
|
// ESLint feliz: "data" ya no puede ser null a este punto.
|
||||||
|
const count = data.length > 0 ? Number(data[0].total_count) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ESLint feliz: map directo sobre data
|
||||||
|
data: data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
nombre: r.nombre,
|
||||||
|
codigo: r.codigo,
|
||||||
|
creditos: Number(r.creditos),
|
||||||
|
tipo: r.tipo,
|
||||||
|
plan_estudio_id: r.plan_estudio_id,
|
||||||
|
estructura_id: null,
|
||||||
|
rank: r.rank,
|
||||||
|
})),
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const subjects = subjectsPaged?.data ?? []
|
||||||
|
const total = subjectsPaged?.count ?? 0
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// clamp page if results shrink
|
||||||
|
if (page > pageCount) {
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [onChange, page, pageCount])
|
||||||
|
|
||||||
|
const patchClonInterno = (
|
||||||
|
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
|
||||||
|
) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hasAnyFilter = Boolean(
|
||||||
|
facultadId || carreraId || planOrigenId || search.trim().length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearDisabled = !hasAnyFilter
|
||||||
|
|
||||||
|
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||||
|
|
||||||
|
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Card className="gap-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Fuente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<Select
|
||||||
|
value={facultadId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({
|
||||||
|
facultadId: next,
|
||||||
|
carreraId: null,
|
||||||
|
planOrigenId: null,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||||
|
<SelectValue placeholder="Todas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todas</SelectItem>
|
||||||
|
{(catalogos?.facultades ?? []).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Carrera</Label>
|
||||||
|
<Select
|
||||||
|
value={carreraId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({
|
||||||
|
carreraId: next,
|
||||||
|
planOrigenId: null,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
disabled={!facultadId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todas</SelectItem>
|
||||||
|
{carrerasOptions.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<Select
|
||||||
|
value={planOrigenId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({ planOrigenId: next, page: 1 })
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||||
|
disabled={!carreraId && !facultadId}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todos</SelectItem>
|
||||||
|
{(planesQuery.data?.data ?? []).map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Buscar</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Nombre o código..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchClonInterno({ search: e.target.value, page: 1 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
patchClonInterno({
|
||||||
|
facultadId: null,
|
||||||
|
carreraId: null,
|
||||||
|
planOrigenId: null,
|
||||||
|
search: '',
|
||||||
|
page: 1,
|
||||||
|
asignaturaOrigenId: null,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={clearDisabled}
|
||||||
|
>
|
||||||
|
<Icons.X className="mr-2 h-4 w-4" />
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Selecciona una asignatura fuente (solo una).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-h-80 gap-2 overflow-y-auto px-1">
|
||||||
|
{subjectsLoading ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Cargando asignaturas…
|
||||||
|
</div>
|
||||||
|
) : subjects.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
No hay asignaturas con esos filtros.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
subjects.map((m) => {
|
||||||
|
const active = String(selectedId) === String(m.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
|
||||||
|
active && 'border-primary bg-primary/5 ring-primary ring-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="radio"
|
||||||
|
name="asignaturaFuente"
|
||||||
|
checked={active}
|
||||||
|
onChange={() =>
|
||||||
|
patchClonInterno({ asignaturaOrigenId: m.id, page })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{m.nombre}</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{(m.codigo ? m.codigo : '—') +
|
||||||
|
' • ' +
|
||||||
|
String(m.creditos) +
|
||||||
|
' créditos'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
|
||||||
|
) : (
|
||||||
|
<span className="h-5 w-5 flex-none" aria-hidden />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<Pagination03
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
qk,
|
qk,
|
||||||
useCreateSubjectManual,
|
useCreateSubjectManual,
|
||||||
subjects_get_maybe,
|
subjects_get_maybe,
|
||||||
|
subjects_get,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
@@ -201,6 +202,87 @@ export function WizardControls({
|
|||||||
let startedWaiting = false
|
let startedWaiting = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
|
||||||
|
if (!asignaturaOrigenId) {
|
||||||
|
throw new Error('Selecciona una asignatura fuente.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wizard.datosBasicos.estructuraId) {
|
||||||
|
throw new Error('Estructura inválida.')
|
||||||
|
}
|
||||||
|
if (!wizard.datosBasicos.nombre.trim()) {
|
||||||
|
throw new Error('Nombre inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.tipo == null) {
|
||||||
|
throw new Error('Tipo inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.creditos == null) {
|
||||||
|
throw new Error('Créditos inválidos.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuente = await subjects_get(asignaturaOrigenId as any)
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
|
||||||
|
|
||||||
|
const payload: TablesInsert<'asignaturas'> = {
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
|
codigo: codigo ? codigo : null,
|
||||||
|
nombre: wizard.datosBasicos.nombre,
|
||||||
|
tipo: wizard.datosBasicos.tipo,
|
||||||
|
creditos: wizard.datosBasicos.creditos,
|
||||||
|
datos: (fuente as any).datos,
|
||||||
|
contenido_tematico: (fuente as any).contenido_tematico,
|
||||||
|
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
|
||||||
|
tipo_origen: 'CLONADO_INTERNO',
|
||||||
|
meta_origen: {
|
||||||
|
...(fuente as any).meta_origen,
|
||||||
|
asignatura_origen_id: fuente.id,
|
||||||
|
plan_origen_id: (fuente as any).plan_estudio_id,
|
||||||
|
},
|
||||||
|
horas_academicas:
|
||||||
|
wizard.datosBasicos.horasAcademicas ??
|
||||||
|
(fuente as any).horas_academicas ??
|
||||||
|
null,
|
||||||
|
horas_independientes:
|
||||||
|
wizard.datosBasicos.horasIndependientes ??
|
||||||
|
(fuente as any).horas_independientes ??
|
||||||
|
null,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('payload:', payload)
|
||||||
|
|
||||||
|
const { data: inserted, error: insertError } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.insert(payload)
|
||||||
|
.select('id,plan_estudio_id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (insertError) throw new Error(insertError.message)
|
||||||
|
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planHistorial(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
|
||||||
|
state: { showConfetti: true },
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
if (!wizard.plan_estudio_id) {
|
if (!wizard.plan_estudio_id) {
|
||||||
throw new Error('Plan de estudio inválido.')
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
|||||||
@@ -9,6 +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">
|
||||||
|
<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">
|
<h1 className="mb-1 text-center text-2xl font-semibold">
|
||||||
Iniciar sesión
|
Iniciar sesión
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -4,20 +4,18 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||||
import { cn } from '@/lib/utils' // Asegúrate de tener tu utilidad cn
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface PlanEstudiosCardProps {
|
interface PlanEstudiosCardProps {
|
||||||
/** El componente del ícono importado de lucide-react (ej. BookOpen) */
|
|
||||||
Icono: LucideIcon
|
Icono: LucideIcon
|
||||||
nombrePrograma: string
|
nombrePrograma: string
|
||||||
nivel: string
|
nivel: string
|
||||||
ciclos: string | number // Acepta "8" o "8 semestres"
|
ciclos: string | number
|
||||||
facultad: string
|
facultad: string
|
||||||
estado: string
|
estado: string
|
||||||
/** Código hex o variable CSS (ej. "#ef4444" o "var(--primary)") */
|
|
||||||
claseColorEstado?: string
|
claseColorEstado?: string
|
||||||
|
colorEstadoHex?: string
|
||||||
colorFacultad: string
|
colorFacultad: string
|
||||||
/** Opcional: para manejar el click en la tarjeta */
|
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,52 +27,62 @@ 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}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex h-full cursor-pointer flex-col justify-between gap-2 overflow-hidden border-l-4 transition-all hover:shadow-lg',
|
'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
|
||||||
)}
|
)}
|
||||||
// Aplicamos el color de la facultad dinámicamente al borde y un fondo muy sutil
|
|
||||||
style={{
|
|
||||||
borderLeftColor: colorFacultad,
|
|
||||||
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 95%)`, // Truco CSS moderno para fondo tintado
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<div className="flex grow flex-col">
|
||||||
{/* Ícono con el color de la facultad */}
|
<CardHeader className="pb-2">
|
||||||
<div
|
{/* Círculo del ícono con el color de la facultad */}
|
||||||
className="mb-2 w-fit rounded-md p-2"
|
<div
|
||||||
style={{
|
className="mb-2 w-fit rounded-full p-2.5"
|
||||||
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
style={{
|
||||||
}}
|
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icono size={24} style={{ color: colorFacultad }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Título del Programa */}
|
||||||
|
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
|
||||||
|
{nombrePrograma}
|
||||||
|
</h4>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
{nivel} • {ciclos}
|
||||||
|
</p>
|
||||||
|
<p>{facultad}</p>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardFooter className="flex items-center justify-between pt-0 pb-6">
|
||||||
|
<Badge
|
||||||
|
style={badgeStyle}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-semibold',
|
||||||
|
!colorEstadoHex && claseColorEstado,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icono size={24} style={{ color: colorFacultad }} />
|
<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]">
|
||||||
</div>
|
{estado}
|
||||||
|
</span>
|
||||||
{/* Título del Programa */}
|
|
||||||
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
|
|
||||||
{nombrePrograma}
|
|
||||||
</h4>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{nivel} • {ciclos}
|
|
||||||
</p>
|
|
||||||
<p>{facultad}</p>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="bg-background/50 flex items-center justify-between border-t px-6 pb-3 backdrop-blur-sm [.border-t]:pt-3">
|
|
||||||
<Badge className={`text-sm font-semibold ${claseColorEstado}`}>
|
|
||||||
{estado}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* <span className="text-foreground/80 text-sm font-semibold">
|
|
||||||
{estado}
|
|
||||||
</span> */}
|
|
||||||
|
|
||||||
{/* Flecha animada */}
|
{/* Flecha animada */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -56,10 +56,9 @@ export const ImprovementCard = ({
|
|||||||
|
|
||||||
if (onApplySuccess) onApplySuccess(key)
|
if (onApplySuccess) onApplySuccess(key)
|
||||||
|
|
||||||
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
|
|
||||||
if (dbMessageId) {
|
if (dbMessageId) {
|
||||||
updateAppliedStatus.mutate({
|
updateAppliedStatus.mutate({
|
||||||
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
|
conversacionId: dbMessageId,
|
||||||
campoAfectado: key,
|
campoAfectado: key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -81,21 +80,18 @@ export const ImprovementCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sug.key}
|
key={sug.key}
|
||||||
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
|
className={`bg-card rounded-2xl border p-5 shadow-sm transition-all ${
|
||||||
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
|
isApplied ? 'border-primary/30 bg-primary/5' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
|
<h3 className="text-foreground text-sm font-bold">{sug.label}</h3>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleApply(sug.key, sug.newValue)}
|
onClick={() => handleApply(sug.key, sug.newValue)}
|
||||||
disabled={isApplied || !!isUpdating}
|
disabled={isApplied || !!isUpdating}
|
||||||
className={`h-8 rounded-full px-4 text-xs transition-all ${
|
variant={isApplied ? 'secondary' : 'default'}
|
||||||
isApplied
|
className="h-8 rounded-full px-4 text-xs transition-all"
|
||||||
? 'cursor-not-allowed bg-slate-100 text-slate-400'
|
|
||||||
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isUpdating ? (
|
{isUpdating ? (
|
||||||
<Loader2 size={12} className="animate-spin" />
|
<Loader2 size={12} className="animate-spin" />
|
||||||
@@ -112,8 +108,8 @@ export const ImprovementCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
|
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
|
||||||
isApplied
|
isApplied
|
||||||
? 'border-teal-100 bg-teal-50/50 text-slate-700'
|
? 'border-primary/20 bg-primary/10 text-foreground'
|
||||||
: 'border-slate-200 bg-slate-50 text-slate-500'
|
: 'border-border bg-muted/50 text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sug.newValue}
|
{sug.newValue}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type Pagination03Props = {
|
||||||
|
page: number
|
||||||
|
pageCount: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInt = (n: unknown, fallback: number) => {
|
||||||
|
const x = typeof n === 'number' ? n : Number(n)
|
||||||
|
return Number.isFinite(x) ? Math.floor(x) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
return Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Array<number | '...'> = []
|
||||||
|
const safePage = Math.min(Math.max(page, 1), pageCount)
|
||||||
|
|
||||||
|
items.push(1)
|
||||||
|
|
||||||
|
const start = Math.max(2, safePage - 1)
|
||||||
|
const end = Math.min(pageCount - 1, safePage + 1)
|
||||||
|
|
||||||
|
if (start > 2) items.push('...')
|
||||||
|
for (let p = start; p <= end; p++) items.push(p)
|
||||||
|
if (end < pageCount - 1) items.push('...')
|
||||||
|
|
||||||
|
items.push(pageCount)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination03({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
}: Pagination03Props) {
|
||||||
|
const safePageCount = Math.max(1, toInt(pageCount, 1))
|
||||||
|
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
|
||||||
|
|
||||||
|
const items = getPageItems(safePage, safePageCount)
|
||||||
|
const canPrev = safePage > 1
|
||||||
|
const canNext = safePage < safePageCount
|
||||||
|
|
||||||
|
const go = (p: number) => {
|
||||||
|
const next = Math.min(Math.max(p, 1), safePageCount)
|
||||||
|
if (next === safePage) return
|
||||||
|
onPageChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination className={className}>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
className={cn(!canPrev && 'pointer-events-none opacity-50')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canPrev) return
|
||||||
|
go(safePage - 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{items.map((it, idx) =>
|
||||||
|
it === '...' ? (
|
||||||
|
<PaginationItem key={`ellipsis-${idx}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={it}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive={it === safePage}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
go(it)
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
it === safePage
|
||||||
|
? cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: 'default',
|
||||||
|
size: 'icon',
|
||||||
|
}),
|
||||||
|
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{it}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
className={cn(!canNext && 'pointer-events-none opacity-50')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canNext) return
|
||||||
|
go(safePage + 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination03
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants, type Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function WizardLayout({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="z-10 flex-none border-b bg-white">
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-3">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -36,10 +36,10 @@ export function WizardLayout({
|
|||||||
</button>
|
</button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
|
{headerSlot ? <div className="px-6 pb-3">{headerSlot}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
<div className="flex-1 overflow-y-auto bg-gray-50/30 px-4 py-3 xl:px-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ const cleanText = (text: string) => {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recalculoVectoresAsignaturasInFlight = new Set<string>()
|
||||||
|
|
||||||
|
function triggerRecalculoVectoresAsignaturasNonBlocking(
|
||||||
|
supabase: ReturnType<typeof supabaseBrowser>,
|
||||||
|
planId: UUID,
|
||||||
|
) {
|
||||||
|
const key = String(planId)
|
||||||
|
if (recalculoVectoresAsignaturasInFlight.has(key)) return
|
||||||
|
|
||||||
|
recalculoVectoresAsignaturasInFlight.add(key)
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const { error } = await supabase.rpc('recalcular_vectores_asignaturas')
|
||||||
|
if (error) {
|
||||||
|
// No debe bloquear ni romper el flujo principal.
|
||||||
|
console.warn(
|
||||||
|
'[recalcular_vectores_asignaturas] RPC error:',
|
||||||
|
error.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('[recalcular_vectores_asignaturas] RPC failed:', err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
recalculoVectoresAsignaturasInFlight.delete(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function plans_list(
|
export async function plans_list(
|
||||||
filters: PlanListFilters = {},
|
filters: PlanListFilters = {},
|
||||||
): Promise<Paged<PlanEstudio>> {
|
): Promise<Paged<PlanEstudio>> {
|
||||||
@@ -192,7 +221,9 @@ export async function plan_lineas_list(
|
|||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('lineas_plan')
|
.from('lineas_plan')
|
||||||
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
|
.select(
|
||||||
|
'id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en,color',
|
||||||
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('orden', { ascending: true })
|
.order('orden', { ascending: true })
|
||||||
|
|
||||||
@@ -207,7 +238,7 @@ export async function plan_asignaturas_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id',
|
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,criterios_de_evaluacion,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id,search_vector',
|
||||||
)
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
@@ -215,6 +246,13 @@ export async function plan_asignaturas_list(
|
|||||||
.order('nombre', { ascending: true })
|
.order('nombre', { ascending: true })
|
||||||
|
|
||||||
throwIfError(error)
|
throwIfError(error)
|
||||||
|
|
||||||
|
// No bloqueante: si el primer registro viene sin vector, dispara el recalculo.
|
||||||
|
const first: any = (data as any)?.[0]
|
||||||
|
if (first && first.search_vector === null) {
|
||||||
|
triggerRecalculoVectoresAsignaturasNonBlocking(supabase, planId)
|
||||||
|
}
|
||||||
|
|
||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
|||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
|
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
|
||||||
planes_estudio(
|
planes_estudio(
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||||
@@ -499,6 +499,7 @@ export async function lineas_insert(linea: {
|
|||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
orden: number
|
orden: number
|
||||||
area?: string
|
area?: string
|
||||||
|
color?: string | null
|
||||||
}) {
|
}) {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -514,7 +515,12 @@ export async function lineas_insert(linea: {
|
|||||||
// Actualizar una línea existente
|
// Actualizar una línea existente
|
||||||
export async function lineas_update(
|
export async function lineas_update(
|
||||||
lineaId: string,
|
lineaId: string,
|
||||||
patch: { nombre?: string; orden?: number; area?: string },
|
patch: {
|
||||||
|
nombre?: string
|
||||||
|
orden?: number
|
||||||
|
area?: string
|
||||||
|
color?: string | null
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -585,3 +591,49 @@ export async function bibliografia_delete(id: string) {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkPrerrequisitoConflicts(
|
||||||
|
asignaturaId: string,
|
||||||
|
nuevoCiclo: number,
|
||||||
|
): Promise<Array<string>> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
// CORRECCIÓN 1: Agregamos 'id' al select
|
||||||
|
// CORRECCIÓN 2: Quitamos espacios en el .or()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select('id, nombre, numero_ciclo, prerrequisito_asignatura_id')
|
||||||
|
.or(`prerrequisito_asignatura_id.eq.${asignaturaId},id.eq.${asignaturaId}`)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
// CORRECCIÓN 3: 'data' ahora está disponible porque lo desestructuramos arriba
|
||||||
|
if (!data) return []
|
||||||
|
|
||||||
|
const conflictos: Array<string> = []
|
||||||
|
|
||||||
|
data.forEach((asig) => {
|
||||||
|
// Caso 1: Materias que tienen a esta como prerrequisito (Hijas)
|
||||||
|
// "Si yo me muevo al ciclo 5, y mi hija está en el 4, se rompe la regla"
|
||||||
|
if (asig.prerrequisito_asignatura_id === asignaturaId) {
|
||||||
|
if (asig.numero_ciclo !== null && asig.numero_ciclo <= nuevoCiclo) {
|
||||||
|
conflictos.push(asig.nombre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 2: El prerrequisito de la materia que estoy moviendo (Padre)
|
||||||
|
// "Si yo me muevo al ciclo 2, y mi padre está en el 3, se rompe la regla"
|
||||||
|
if (asig.id === asignaturaId && asig.prerrequisito_asignatura_id) {
|
||||||
|
const padre = data.find((p) => p.id === asig.prerrequisito_asignatura_id)
|
||||||
|
if (
|
||||||
|
padre &&
|
||||||
|
padre.numero_ciclo !== null &&
|
||||||
|
padre.numero_ciclo >= nuevoCiclo
|
||||||
|
) {
|
||||||
|
conflictos.push(padre.nombre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return conflictos
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ai_generate_subject,
|
ai_generate_subject,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
bibliografia_delete,
|
bibliografia_delete,
|
||||||
bibliografia_insert,
|
bibliografia_insert,
|
||||||
bibliografia_update,
|
bibliografia_update,
|
||||||
|
checkPrerrequisitoConflicts,
|
||||||
lineas_insert,
|
lineas_insert,
|
||||||
lineas_update,
|
lineas_update,
|
||||||
subjects_bibliografia_list,
|
subjects_bibliografia_list,
|
||||||
@@ -317,3 +319,34 @@ export function useDeleteBibliografia(asignaturaId: string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAsignaturaConflictos() {
|
||||||
|
const [isValidating, setIsValidating] = useState(false)
|
||||||
|
|
||||||
|
const validarCambioCiclo = async (
|
||||||
|
asignaturaId: string,
|
||||||
|
nuevoCiclo: number,
|
||||||
|
) => {
|
||||||
|
setIsValidating(true)
|
||||||
|
try {
|
||||||
|
const nombresConflictivos = await checkPrerrequisitoConflicts(
|
||||||
|
asignaturaId,
|
||||||
|
nuevoCiclo,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (nombresConflictivos.length > 0) {
|
||||||
|
const mensaje = `Si mueves esta materia al ciclo ${nuevoCiclo}, se perderá la seriación con:\n\n• ${nombresConflictivos.join('\n• ')}\n\n¿Deseas continuar?`
|
||||||
|
return confirm(mensaje) // Puedes usar un Modal de Shadcn aquí en lugar de confirm
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // Sin conflictos
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { validarCambioCiclo, isValidating }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import * as Icons from 'lucide-react'
|
|||||||
|
|
||||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||||
|
|
||||||
|
import { PasoBasicosClonadoInterno } from '@/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx'
|
||||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
||||||
|
import { PasoFuenteClonadoInterno } from '@/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx'
|
||||||
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
||||||
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||||
@@ -63,7 +65,12 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
basicos: 'Sugerencias',
|
basicos: 'Sugerencias',
|
||||||
detalles: 'Estructura',
|
detalles: 'Estructura',
|
||||||
}
|
}
|
||||||
: undefined
|
: wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? {
|
||||||
|
basicos: 'Fuente',
|
||||||
|
detalles: 'Datos básicos',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
||||||
@@ -99,6 +106,21 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
>
|
>
|
||||||
{({ methods }) => {
|
{({ methods }) => {
|
||||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||||
|
const stepId = methods.current.id
|
||||||
|
|
||||||
|
const disableNext =
|
||||||
|
wizard.isLoading ||
|
||||||
|
(stepId === 'metodo'
|
||||||
|
? !canContinueDesdeMetodo
|
||||||
|
: stepId === 'basicos'
|
||||||
|
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? !canContinueDesdeDetalles
|
||||||
|
: !canContinueDesdeBasicos
|
||||||
|
: stepId === 'detalles'
|
||||||
|
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? !canContinueDesdeBasicos
|
||||||
|
: !canContinueDesdeDetalles
|
||||||
|
: false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
@@ -118,12 +140,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
onPrev={() => methods.prev()}
|
onPrev={() => methods.prev()}
|
||||||
onNext={() => methods.next()}
|
onNext={() => methods.next()}
|
||||||
disablePrev={idx === 0 || wizard.isLoading}
|
disablePrev={idx === 0 || wizard.isLoading}
|
||||||
disableNext={
|
disableNext={disableNext}
|
||||||
wizard.isLoading ||
|
|
||||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
|
||||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
|
||||||
(idx === 2 && !canContinueDesdeDetalles)
|
|
||||||
}
|
|
||||||
disableCreate={wizard.isLoading}
|
disableCreate={wizard.isLoading}
|
||||||
isLastStep={idx >= Wizard.steps.length - 1}
|
isLastStep={idx >= Wizard.steps.length - 1}
|
||||||
wizard={wizard}
|
wizard={wizard}
|
||||||
@@ -141,13 +158,27 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
|
|
||||||
{idx === 1 && (
|
{idx === 1 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||||
|
<PasoFuenteClonadoInterno
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||||
|
)}
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{idx === 2 && (
|
{idx === 2 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||||
|
<PasoBasicosClonadoInterno
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||||
|
)}
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ export type NewSubjectWizardState = {
|
|||||||
}
|
}
|
||||||
sugerencias: Array<AsignaturaSugerida>
|
sugerencias: Array<AsignaturaSugerida>
|
||||||
clonInterno?: {
|
clonInterno?: {
|
||||||
facultadId?: string
|
facultadId?: string | null
|
||||||
carreraId?: string
|
carreraId?: string | null
|
||||||
planOrigenId?: string
|
planOrigenId?: string | null
|
||||||
asignaturaOrigenId?: string | null
|
asignaturaOrigenId?: string | null
|
||||||
|
search?: string
|
||||||
|
page?: number
|
||||||
}
|
}
|
||||||
clonTradicional?: {
|
clonTradicional?: {
|
||||||
archivoWordAsignaturaId: string | null
|
archivoWordAsignaturaId: string | null
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function RouteComponent() {
|
|||||||
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
|
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
|
||||||
5. py-6: Padding vertical (opcional, para separarse del header).
|
5. py-6: Padding vertical (opcional, para separarse del header).
|
||||||
*/}
|
*/}
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
<div className="mx-auto flex w-full flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
nombre="Dr. Carlos Mendoza"
|
nombre="Dr. Carlos Mendoza"
|
||||||
rol="Jefe de Carrera"
|
rol="Jefe de Carrera"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Route = createFileRoute('/login')({
|
|||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#7b0f1d] via-[#6b0d1a] to-[#3a050a]">
|
<div className="login-bg flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat">
|
||||||
<LoginCard />
|
<LoginCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
@@ -114,20 +118,20 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="bg-background min-h-screen">
|
||||||
{/* 1. Header Superior */}
|
{/* 1. Header Superior */}
|
||||||
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
|
<div className="bg-background/80 sticky top-0 z-20 border-b shadow-sm backdrop-blur-sm">
|
||||||
<div className="px-6 py-2">
|
<div className="px-6 py-2">
|
||||||
<Link
|
<Link
|
||||||
to="/planes"
|
to="/planes"
|
||||||
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
|
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-xs transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} /> Volver a planes
|
<ChevronLeft size={14} /> Volver a planes
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-400 space-y-8 p-8">
|
<div className="mx-auto space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
||||||
{/* 2. Header del Plan */}
|
{/* 2. Header del Plan */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
/* ===== SKELETON ===== */
|
/* ===== SKELETON ===== */
|
||||||
@@ -139,7 +143,7 @@ function RouteComponent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
|
<h1 className="text-foreground flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight">
|
||||||
{/* El prefijo "Nivel en" lo mantenemos simple */}
|
{/* El prefijo "Nivel en" lo mantenemos simple */}
|
||||||
<span className="shrink-0">{nivelPlan} en</span>
|
<span className="shrink-0">{nivelPlan} en</span>
|
||||||
<span
|
<span
|
||||||
@@ -148,80 +152,105 @@ 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} // Añadido para controlar lo que pegan
|
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 } })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
|
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"
|
||||||
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
>
|
>
|
||||||
{nombrePlan}
|
{nombrePlan}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-lg font-medium text-slate-500">
|
<p className="text-muted-foreground mt-1 text-lg font-medium">
|
||||||
{data?.carreras?.facultades?.nombre}{' '}
|
{data?.carreras?.facultades?.nombre}{' '}
|
||||||
{data?.carreras?.nombre_corto}
|
{data?.carreras?.nombre_corto}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{(() => {
|
||||||
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
|
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-slate-400" />}
|
</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-slate-400" />}
|
icon={<Clock className="text-muted-foreground" />}
|
||||||
label="Duración"
|
label="Duración"
|
||||||
value={`${data?.numero_ciclos || 0} Ciclos`}
|
value={`${data?.numero_ciclos || 0} Ciclos`}
|
||||||
/>
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
icon={<Hash className="text-slate-400" />}
|
icon={<Hash className="text-muted-foreground" />}
|
||||||
label="Créditos"
|
label="Créditos"
|
||||||
value="320"
|
value="320"
|
||||||
/>
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
icon={<CalendarDays className="text-slate-400" />}
|
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>
|
||||||
|
|
||||||
@@ -276,20 +305,20 @@ const InfoCard = forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
|
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 ${
|
||||||
isEditable
|
isEditable
|
||||||
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
|
? 'hover:border-primary/50 hover:bg-accent focus-visible:ring-primary/40 cursor-pointer focus:outline-none focus-visible:ring-2'
|
||||||
: ''
|
: ''
|
||||||
} ${className ?? ''}`}
|
} ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
|
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-sm font-semibold text-slate-700">
|
<p className="text-foreground truncate text-sm font-semibold">
|
||||||
{value || '---'}
|
{value || '---'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,8 +340,8 @@ function Tab({
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
params={params}
|
params={params}
|
||||||
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
|
className="text-muted-foreground hover:text-foreground border-b-2 border-transparent pb-3 text-sm font-medium transition-all"
|
||||||
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
|
activeProps={{ className: 'border-primary text-primary font-bold' }}
|
||||||
activeOptions={{
|
activeOptions={{
|
||||||
exact: true,
|
exact: true,
|
||||||
}}
|
}}
|
||||||
@@ -324,7 +353,7 @@ function Tab({
|
|||||||
|
|
||||||
function DatosGeneralesSkeleton() {
|
function DatosGeneralesSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white">
|
<div className="bg-card rounded-xl border">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b px-5 py-3">
|
<div className="flex items-center justify-between border-b px-5 py-3">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-4 w-40" />
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const mapAsignaturas = (
|
|||||||
estado: asig.estado,
|
estado: asig.estado,
|
||||||
hd: asig.horas_academicas ?? 0,
|
hd: asig.horas_academicas ?? 0,
|
||||||
hi: asig.horas_independientes ?? 0,
|
hi: asig.horas_independientes ?? 0,
|
||||||
prerrequisitos: [],
|
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ function AsignaturasPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 px-6 py-6">
|
<div className="w-full space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -152,7 +152,7 @@ function AsignaturasPage() {
|
|||||||
resetScroll: false,
|
resetScroll: false,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
className="shadow-md"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
|
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
|
||||||
</Button>
|
</Button>
|
||||||
@@ -218,26 +218,30 @@ function AsignaturasPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50">
|
<TableRow className="bg-slate-50/50">
|
||||||
<TableHead className="w-30">Clave</TableHead>
|
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
|
||||||
<TableHead>Nombre</TableHead>
|
<TableHead className="px-6 py-4">Nombre</TableHead>
|
||||||
<TableHead className="text-center">Créditos</TableHead>
|
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
|
||||||
<TableHead className="text-center">Ciclo</TableHead>
|
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
|
||||||
<TableHead>Línea Curricular</TableHead>
|
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
|
||||||
<TableHead>Tipo</TableHead>
|
<TableHead className="px-6 py-4">Tipo</TableHead>
|
||||||
<TableHead>Estado</TableHead>
|
<TableHead className="px-6 py-4">Estado</TableHead>
|
||||||
<TableHead className="w-12.5"></TableHead>
|
<TableHead className="w-12.5 px-6 py-4"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAsignaturas.length === 0 ? (
|
{filteredAsignaturas.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-40 text-center">
|
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
|
||||||
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
|
||||||
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
<BookOpen className="h-10 w-10 opacity-20" />
|
||||||
<p className="font-medium">No se encontraron asignaturas</p>
|
<div>
|
||||||
<p className="text-xs">
|
<p className="font-medium">
|
||||||
Intenta cambiar los filtros de búsqueda
|
No se encontraron asignaturas
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
Intenta cambiar los filtros de búsqueda
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -251,25 +255,25 @@ function AsignaturasPage() {
|
|||||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
params: {
|
params: {
|
||||||
planId,
|
planId,
|
||||||
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
|
asignaturaId: asignatura.id,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
realId: asignatura.id, // 👈 ID largo oculto
|
realId: asignatura.id,
|
||||||
asignaturaId: asignatura.id,
|
asignaturaId: asignatura.id,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono text-xs font-bold text-slate-400">
|
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
|
||||||
{asignatura.clave}
|
{asignatura.clave}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-semibold text-slate-700">
|
<TableCell className="px-6 py-4 font-semibold text-slate-700">
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center font-medium">
|
<TableCell className="px-6 py-4 text-center font-medium">
|
||||||
{asignatura.creditos}
|
{asignatura.creditos}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="px-6 py-4 text-center">
|
||||||
{asignatura.ciclo ? (
|
{asignatura.ciclo ? (
|
||||||
<Badge variant="outline" className="font-normal">
|
<Badge variant="outline" className="font-normal">
|
||||||
Ciclo {asignatura.ciclo}
|
Ciclo {asignatura.ciclo}
|
||||||
@@ -278,10 +282,10 @@ function AsignaturasPage() {
|
|||||||
<span className="text-slate-300">—</span>
|
<span className="text-slate-300">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-slate-600">
|
<TableCell className="px-6 py-4 text-sm text-slate-600">
|
||||||
{getLineaNombre(asignatura.lineaCurricularId)}
|
{getLineaNombre(asignatura.lineaCurricularId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-6 py-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
||||||
@@ -289,7 +293,7 @@ function AsignaturasPage() {
|
|||||||
{tipoConfig[asignatura.tipo].label}
|
{tipoConfig[asignatura.tipo].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-6 py-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
||||||
@@ -297,7 +301,7 @@ function AsignaturasPage() {
|
|||||||
{statusConfig[asignatura.estado].label}
|
{statusConfig[asignatura.estado].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-6 py-4">
|
||||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<ChevronRight className="h-5 w-5 text-slate-400" />
|
<ChevronRight className="h-5 w-5 text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -122,11 +122,7 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<RefreshCcw size={16} /> Regenerar
|
<RefreshCcw size={16} /> Regenerar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" className="gap-2" onClick={handleDownloadWord}>
|
||||||
size="sm"
|
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
|
||||||
onClick={handleDownloadWord}
|
|
||||||
>
|
|
||||||
<Download size={16} /> Descargar Word
|
<Download size={16} /> Descargar Word
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function RouteComponent() {
|
|||||||
console.log(rawData)
|
console.log(rawData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -146,7 +146,7 @@ function RouteComponent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full bg-teal-600 hover:bg-teal-700" disabled>
|
<Button className="w-full" disabled>
|
||||||
Avanzar a Revisión Expertos
|
Avanzar a Revisión Expertos
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
User,
|
User,
|
||||||
Loader2,
|
Loader2,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
|
||||||
History,
|
History,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -37,24 +36,24 @@ const getEventConfig = (tipo: string, campo: string) => {
|
|||||||
return {
|
return {
|
||||||
label: 'Creación',
|
label: 'Creación',
|
||||||
icon: <PlusCircle className="h-4 w-4" />,
|
icon: <PlusCircle className="h-4 w-4" />,
|
||||||
color: 'teal',
|
color: 'primary',
|
||||||
}
|
}
|
||||||
if (campo === 'estado')
|
if (campo === 'estado')
|
||||||
return {
|
return {
|
||||||
label: 'Cambio de estado',
|
label: 'Cambio de estado',
|
||||||
icon: <GitBranch className="h-4 w-4" />,
|
icon: <GitBranch className="h-4 w-4" />,
|
||||||
color: 'blue',
|
color: 'secondary',
|
||||||
}
|
}
|
||||||
if (campo === 'datos')
|
if (campo === 'datos')
|
||||||
return {
|
return {
|
||||||
label: 'Edición de Datos',
|
label: 'Edición de Datos',
|
||||||
icon: <Edit3 className="h-4 w-4" />,
|
icon: <Edit3 className="h-4 w-4" />,
|
||||||
color: 'amber',
|
color: 'accent',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: 'Actualización',
|
label: 'Actualización',
|
||||||
icon: <RefreshCw className="h-4 w-4" />,
|
icon: <RefreshCw className="h-4 w-4" />,
|
||||||
color: 'slate',
|
color: 'muted',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ function RouteComponent() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [rawData])
|
}, [rawData, structure, data])
|
||||||
|
|
||||||
const openCompareModal = (event: any) => {
|
const openCompareModal = (event: any) => {
|
||||||
setSelectedEvent(event)
|
setSelectedEvent(event)
|
||||||
@@ -120,16 +119,16 @@ function RouteComponent() {
|
|||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl p-6">
|
<div className="mx-auto">
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<div className="mb-8 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
<h1 className="text-foreground flex items-center gap-2 text-xl font-bold">
|
||||||
<Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
|
<Clock className="text-primary h-5 w-5" /> Historial de Cambios del
|
||||||
Plan
|
Plan
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -139,25 +138,39 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative space-y-0">
|
<div className="relative space-y-0">
|
||||||
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
|
<div className="bg-border absolute top-0 bottom-0 left-6 w-px md:left-9" />
|
||||||
{historyEvents.length === 0 ? (
|
{historyEvents.length === 0 ? (
|
||||||
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
|
<div className="text-muted-foreground ml-20 py-10">
|
||||||
|
No hay registros.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
historyEvents.map((event) => (
|
historyEvents.map((event) => (
|
||||||
<div key={event.id} className="group relative flex gap-6 pb-8">
|
<div
|
||||||
<div className="relative z-10 flex h-18 flex-col items-center">
|
key={event.id}
|
||||||
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
className="group relative flex gap-3 pb-8 md:gap-6"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex flex-col items-center">
|
||||||
|
<div className="border-background bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 shadow-sm transition-colors">
|
||||||
{event.icon}
|
{event.icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
<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-1">
|
<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 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm font-bold text-slate-800">
|
<span className="text-foreground text-sm font-bold">
|
||||||
{event.type}
|
{event.type}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -172,26 +185,17 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grupo de elementos alineados a la derecha */}
|
{/* Grupo de elementos alineados a la derecha */}
|
||||||
<div className="flex items-center gap-4 text-slate-500">
|
<div className="text-muted-foreground flex flex-wrap items-center gap-3 md:gap-4">
|
||||||
{/* Usuario e Icono */}
|
{/* Usuario e Icono */}
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<User className="h-3.5 w-3.5" />
|
<User className="h-3.5 w-3.5" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{event.user}
|
{event.user}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botón Ver Cambios */}
|
|
||||||
<button
|
|
||||||
onClick={() => openCompareModal(event)}
|
|
||||||
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
|
|
||||||
<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="hidden text-[11px] text-slate-400 md: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')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +203,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
{/* LÍNEA INFERIOR: Descripción */}
|
{/* LÍNEA INFERIOR: Descripción */}
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-muted-foreground text-sm">
|
||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -210,16 +214,16 @@ function RouteComponent() {
|
|||||||
<div className="mt-2 flex items-center gap-1.5">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-red-50 px-1.5 text-[9px] text-red-700"
|
className="bg-destructive/10 text-destructive px-1.5 text-[9px]"
|
||||||
>
|
>
|
||||||
{event.details.from}
|
{event.details.from}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-[10px] text-slate-400">
|
<span className="text-muted-foreground/70 text-[10px]">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
|
className="bg-primary/10 text-primary px-1.5 text-[9px]"
|
||||||
>
|
>
|
||||||
{event.details.to}
|
{event.details.to}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -233,8 +237,8 @@ function RouteComponent() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{historyEvents.length > 0 && (
|
{historyEvents.length > 0 && (
|
||||||
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
|
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-muted-foreground text-xs">
|
||||||
Mostrando {rawData.length} de {totalRecords} cambios
|
Mostrando {rawData.length} de {totalRecords} cambios
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -252,7 +256,7 @@ function RouteComponent() {
|
|||||||
Anterior
|
Anterior
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-slate-700">
|
<span className="text-foreground text-sm font-medium">
|
||||||
Página {page + 1} de {totalPages || 1}
|
Página {page + 1} de {totalPages || 1}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -263,7 +267,6 @@ function RouteComponent() {
|
|||||||
setPage((p) => p + 1)
|
setPage((p) => p + 1)
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}}
|
}}
|
||||||
// Ahora se deshabilita si llegamos a la última página real
|
|
||||||
disabled={page + 1 >= totalPages || isLoading}
|
disabled={page + 1 >= totalPages || isLoading}
|
||||||
>
|
>
|
||||||
Siguiente
|
Siguiente
|
||||||
@@ -277,9 +280,9 @@ function RouteComponent() {
|
|||||||
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
|
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader className="border-b bg-slate-50/50 p-6">
|
<DialogHeader className="bg-muted/50 border-b p-6">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<History className="h-5 w-5 text-teal-600" /> Comparación de
|
<History className="text-primary h-5 w-5" /> Comparación de
|
||||||
Versiones
|
Versiones
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
|
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
|
||||||
@@ -298,17 +301,16 @@ function RouteComponent() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="grid h-full grid-cols-2 gap-6">
|
<div className="grid h-full grid-cols-2 gap-6">
|
||||||
{/* Lado Antes */}
|
|
||||||
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
|
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
|
||||||
{selectedEvent?.details.from && (
|
{selectedEvent?.details.from && (
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
|
||||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
<div className="bg-destructive h-2 w-2 rounded-full" />
|
||||||
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
||||||
Versión Anterior
|
Versión Anterior
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
<div className="border-destructive/20 bg-destructive/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||||
{renderValue(selectedEvent.details.from)}
|
{renderValue(selectedEvent.details.from)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,27 +318,22 @@ function RouteComponent() {
|
|||||||
|
|
||||||
{/* Lado Después */}
|
{/* Lado Después */}
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
|
||||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
<div className="bg-primary h-2 w-2 rounded-full" />
|
||||||
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
||||||
Nueva Versión
|
Nueva Versión
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
<div className="border-primary/20 bg-primary/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||||
{renderValue(selectedEvent?.details.to)}
|
{renderValue(selectedEvent?.details.to)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center border-t bg-slate-50 p-4">
|
<div className="bg-muted/30 flex justify-center border-t p-4">
|
||||||
<Badge variant="outline" className="font-mono text-[10px]">
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
Campo: {selectedEvent?.campo}
|
Campo: {selectedEvent?.campo}
|
||||||
{console.log(
|
|
||||||
data?.estructuras_plan?.definicion?.properties?.[
|
|
||||||
selectedEvent?.campo
|
|
||||||
]?.title,
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
useMessagesByChat,
|
useMessagesByChat,
|
||||||
useUpdateConversationStatus,
|
useUpdateConversationStatus,
|
||||||
useUpdateConversationTitle,
|
useUpdateConversationTitle,
|
||||||
|
useUpdatePlanFields,
|
||||||
|
useUpdateRecommendationApplied,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
import { usePlan } from '@/data/hooks/usePlans'
|
import { usePlan } from '@/data/hooks/usePlans'
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ function RouteComponent() {
|
|||||||
const { data: lastConversation, isLoading: isLoadingConv } =
|
const { data: lastConversation, isLoading: isLoadingConv } =
|
||||||
useConversationByPlan(planId)
|
useConversationByPlan(planId)
|
||||||
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
|
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
|
||||||
useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null
|
useMessagesByChat(activeChatId ?? null)
|
||||||
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -139,12 +141,21 @@ function RouteComponent() {
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const [filterQuery, setFilterQuery] = useState('')
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||||
|
const [isActionsOpen, setIsActionsOpen] = useState(false)
|
||||||
|
|
||||||
|
const [selectedImprovements, setSelectedImprovements] = useState<
|
||||||
|
Array<string>
|
||||||
|
>([])
|
||||||
|
const updatePlan = useUpdatePlanFields()
|
||||||
|
const updateAppliedStatus = useUpdateRecommendationApplied()
|
||||||
|
|
||||||
const availableFields = useMemo(() => {
|
const availableFields = useMemo(() => {
|
||||||
const definicion = data?.estructuras_plan
|
const definicion = data?.estructuras_plan
|
||||||
?.definicion as EstructuraDefinicion
|
?.definicion as EstructuraDefinicion
|
||||||
|
|
||||||
// Encadenamiento opcional para evitar errores si data es null
|
if (!definicion?.properties) return []
|
||||||
if (!definicion.properties) return []
|
|
||||||
|
|
||||||
return Object.entries(definicion.properties).map(([key, value]) => ({
|
return Object.entries(definicion.properties).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
@@ -157,28 +168,24 @@ function RouteComponent() {
|
|||||||
return availableFields.filter(
|
return availableFields.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
|
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
|
||||||
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
|
!selectedFields.some((s) => s.key === field.key),
|
||||||
)
|
)
|
||||||
}, [availableFields, filterQuery, selectedFields])
|
}, [availableFields, filterQuery, selectedFields])
|
||||||
|
|
||||||
const chatMessages = useMemo(() => {
|
const chatMessages = useMemo(() => {
|
||||||
if (!activeChatId || !mensajesDelChat) return []
|
if (!activeChatId || !mensajesDelChat) return []
|
||||||
|
|
||||||
// flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
|
|
||||||
return mensajesDelChat.flatMap((msg: any) => {
|
return mensajesDelChat.flatMap((msg: any) => {
|
||||||
const messages = []
|
const messages = []
|
||||||
|
|
||||||
// 1. Mensaje del Usuario
|
|
||||||
messages.push({
|
messages.push({
|
||||||
id: `${msg.id}-user`,
|
id: `${msg.id}-user`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: msg.mensaje,
|
content: msg.mensaje,
|
||||||
selectedFields: msg.campos || [], // Aquí están tus campos
|
selectedFields: msg.campos || [],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Mensaje del Asistente (si hay respuesta)
|
|
||||||
if (msg.respuesta) {
|
if (msg.respuesta) {
|
||||||
// Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
|
|
||||||
const rawRecommendations = msg.propuesta?.recommendations || []
|
const rawRecommendations = msg.propuesta?.recommendations || []
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -206,7 +213,92 @@ function RouteComponent() {
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
}, [mensajesDelChat, activeChatId, availableFields])
|
}, [mensajesDelChat, activeChatId, availableFields])
|
||||||
const scrollToBottom = (behavior = 'smooth') => {
|
|
||||||
|
const handleApplyMultiple = async (
|
||||||
|
sugerencias: Array<any>,
|
||||||
|
dbMessageId: string,
|
||||||
|
) => {
|
||||||
|
if (!planId || !data?.datos || sugerencias.length === 0) return
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
try {
|
||||||
|
const datosActualizados = { ...data.datos }
|
||||||
|
|
||||||
|
for (const sug of sugerencias) {
|
||||||
|
const key = sug.key
|
||||||
|
const newValue = sug.newValue
|
||||||
|
const currentValue = datosActualizados[key]
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof currentValue === 'object' &&
|
||||||
|
currentValue !== null &&
|
||||||
|
'description' in currentValue
|
||||||
|
) {
|
||||||
|
datosActualizados[key] = { ...currentValue, description: newValue }
|
||||||
|
} else {
|
||||||
|
datosActualizados[key] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updatePlan.mutateAsync({
|
||||||
|
planId: planId as any,
|
||||||
|
patch: { datos: datosActualizados },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const sug of sugerencias) {
|
||||||
|
try {
|
||||||
|
await updateAppliedStatus.mutateAsync({
|
||||||
|
conversacionId: dbMessageId,
|
||||||
|
campoAfectado: sug.key,
|
||||||
|
})
|
||||||
|
removeSelectedField(sug.key)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Error al marcar aplicada la sugerencia: ${sug.key}`,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImprovements([])
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plan', planId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error crítico en aplicación masiva:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleImprovementSelection = (sugKey: string) => {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
prev.includes(sugKey)
|
||||||
|
? prev.filter((key) => key !== sugKey)
|
||||||
|
: [...prev, sugKey],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllFromMessage = (suggestions: Array<any>) => {
|
||||||
|
const pending = suggestions.filter((s) => !s.applied)
|
||||||
|
const allKeys = pending.map((s) => s.key)
|
||||||
|
const allSelected = allKeys.every((key) =>
|
||||||
|
selectedImprovements.includes(key),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
prev.filter((key) => !allKeys.includes(key)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setSelectedImprovements((prev) =>
|
||||||
|
Array.from(new Set([...prev, ...allKeys])),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const scrollContainer = scrollRef.current.querySelector(
|
const scrollContainer = scrollRef.current.querySelector(
|
||||||
'[data-radix-scroll-area-viewport]',
|
'[data-radix-scroll-area-viewport]',
|
||||||
@@ -214,7 +306,7 @@ function RouteComponent() {
|
|||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollContainer.scrollHeight,
|
top: scrollContainer.scrollHeight,
|
||||||
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
|
behavior,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,17 +325,14 @@ function RouteComponent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatMessages.length > 0) {
|
if (chatMessages.length > 0) {
|
||||||
if (isInitialLoad.current) {
|
if (isInitialLoad.current) {
|
||||||
// Si es el primer render con mensajes, vamos al final al instante
|
|
||||||
scrollToBottom('instant')
|
scrollToBottom('instant')
|
||||||
isInitialLoad.current = false
|
isInitialLoad.current = false
|
||||||
} else {
|
} else {
|
||||||
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
|
|
||||||
scrollToBottom('smooth')
|
scrollToBottom('smooth')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [chatMessages])
|
}, [chatMessages])
|
||||||
|
|
||||||
// 2. Resetear el flag cuando cambies de chat activo
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isInitialLoad.current = true
|
isInitialLoad.current = true
|
||||||
}, [activeChatId])
|
}, [activeChatId])
|
||||||
@@ -256,14 +345,12 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
||||||
|
|
||||||
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
|
|
||||||
if (activeChatId && !currentChatExists && !isCreationMode) {
|
if (activeChatId && !currentChatExists && !isCreationMode) {
|
||||||
setActiveChatId(undefined)
|
setActiveChatId(undefined)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
|
|
||||||
if (
|
if (
|
||||||
!activeChatId &&
|
!activeChatId &&
|
||||||
activeChats.length > 0 &&
|
activeChats.length > 0 &&
|
||||||
@@ -297,7 +384,7 @@ function RouteComponent() {
|
|||||||
}, [availableFields, routerState.location.state])
|
}, [availableFields, routerState.location.state])
|
||||||
|
|
||||||
const createNewChat = () => {
|
const createNewChat = () => {
|
||||||
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
|
setActiveChatId(undefined)
|
||||||
setMessages([
|
setMessages([
|
||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
@@ -306,7 +393,6 @@ function RouteComponent() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
setInput('')
|
setInput('')
|
||||||
// setSelectedFields([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveChat = (e: React.MouseEvent, id: string) => {
|
const archiveChat = (e: React.MouseEvent, id: string) => {
|
||||||
@@ -348,16 +434,15 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const val = e.target.value
|
const val = e.target.value
|
||||||
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
|
const cursorPosition = e.target.selectionStart
|
||||||
setInput(val)
|
setInput(val)
|
||||||
|
|
||||||
// Busca un ":" seguido de letras justo antes del cursor
|
|
||||||
const textBeforeCursor = val.slice(0, cursorPosition)
|
const textBeforeCursor = val.slice(0, cursorPosition)
|
||||||
const match = textBeforeCursor.match(/:(\w*)$/)
|
const match = textBeforeCursor.match(/:(\w*)$/)
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
setShowSuggestions(true)
|
setShowSuggestions(true)
|
||||||
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
|
setFilterQuery(match[1])
|
||||||
} else {
|
} else {
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
setFilterQuery('')
|
setFilterQuery('')
|
||||||
@@ -368,40 +453,31 @@ function RouteComponent() {
|
|||||||
input: string,
|
input: string,
|
||||||
fields: Array<SelectedField>,
|
fields: Array<SelectedField>,
|
||||||
) => {
|
) => {
|
||||||
// 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
|
|
||||||
// Esta regex ahora también limpia si el texto termina de forma natural
|
|
||||||
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
|
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
|
||||||
|
|
||||||
if (fields.length === 0) return cleaned
|
if (fields.length === 0) return cleaned
|
||||||
|
|
||||||
const fieldLabels = fields.map((f) => f.label).join(', ')
|
const fieldLabels = fields.map((f) => f.label).join(', ')
|
||||||
|
|
||||||
// 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
|
|
||||||
return `${cleaned}: ${fieldLabels}`
|
return `${cleaned}: ${fieldLabels}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleField = (field: SelectedField) => {
|
const toggleField = (field: SelectedField) => {
|
||||||
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
|
|
||||||
setSelectedFields((prev) => {
|
setSelectedFields((prev) => {
|
||||||
const isSelected = prev.find((f) => f.key === field.key)
|
const isSelected = prev.find((f) => f.key === field.key)
|
||||||
return isSelected ? prev : [...prev, field]
|
return isSelected ? prev : [...prev, field]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
|
|
||||||
setInput((prev) => {
|
setInput((prev) => {
|
||||||
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
|
|
||||||
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
||||||
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
|
return nuevoTexto + ' '
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. Limpiamos estados de búsqueda
|
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
setFilterQuery('')
|
setFilterQuery('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
||||||
if (fields.length === 0) return userInput
|
if (fields.length === 0) return userInput
|
||||||
|
|
||||||
return ` ${userInput}`
|
return ` ${userInput}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +490,6 @@ function RouteComponent() {
|
|||||||
setIsSending(true)
|
setIsSending(true)
|
||||||
setOptimisticMessage(finalContent)
|
setOptimisticMessage(finalContent)
|
||||||
setInput('')
|
setInput('')
|
||||||
// setSelectedFields([])
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -433,7 +508,6 @@ function RouteComponent() {
|
|||||||
setActiveChatId(response.conversacionId)
|
setActiveChatId(response.conversacionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESPERAMOS a que la caché se actualice antes de quitar el "isSending"
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['conversation-by-plan', planId],
|
queryKey: ['conversation-by-plan', planId],
|
||||||
@@ -446,19 +520,15 @@ function RouteComponent() {
|
|||||||
console.error('Error:', error)
|
console.error('Error:', error)
|
||||||
setOptimisticMessage(null)
|
setOptimisticMessage(null)
|
||||||
} finally {
|
} finally {
|
||||||
// Solo ahora quitamos los indicadores de carga
|
|
||||||
setIsSending(false)
|
setIsSending(false)
|
||||||
// setOptimisticMessage(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
|
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
|
||||||
|
|
||||||
// Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja
|
|
||||||
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
|
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
|
||||||
|
|
||||||
// Ahora la validación es directa y no debería dar avisos de "unnecessary"
|
|
||||||
if (ultimoMensajeDB?.respuesta) {
|
if (ultimoMensajeDB?.respuesta) {
|
||||||
setIsSyncing(false)
|
setIsSyncing(false)
|
||||||
setOptimisticMessage(null)
|
setOptimisticMessage(null)
|
||||||
@@ -478,41 +548,39 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
|
||||||
|
{/* --- HEADER MÓVIL --- */}
|
||||||
|
<div className="bg-background flex shrink-0 items-center justify-between rounded-lg border p-2 shadow-sm md:hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsHistoryOpen(true)}
|
||||||
|
>
|
||||||
|
<Archive size={18} className="mr-2" /> Historial
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsActionsOpen(true)}
|
||||||
|
>
|
||||||
|
<Lightbulb size={18} className="text-primary mr-2" /> Acciones
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
|
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
|
||||||
<div className="flex w-64 flex-col border-r pr-4">
|
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
|
||||||
<div className="mb-4">
|
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
|
||||||
<div className="mb-4 flex items-center justify-between px-2">
|
Chats
|
||||||
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
|
</h2>
|
||||||
Chats
|
<Button
|
||||||
</h2>
|
onClick={createNewChat}
|
||||||
{/* Botón de toggle archivados movido aquí arriba */}
|
variant="outline"
|
||||||
<button
|
className="mt-2 mb-4 w-full justify-start gap-2"
|
||||||
onClick={() => setShowArchived(!showArchived)}
|
>
|
||||||
className={`rounded-md p-1.5 transition-colors ${
|
<MessageSquarePlus size={18} /> Nuevo chat
|
||||||
showArchived
|
</Button>
|
||||||
? 'bg-teal-50 text-teal-600'
|
|
||||||
: 'text-slate-400 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
|
|
||||||
>
|
|
||||||
<Archive size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={createNewChat}
|
|
||||||
variant="outline"
|
|
||||||
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
|
|
||||||
>
|
|
||||||
<MessageSquarePlus size={18} /> Nuevo chat
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 pr-2">
|
<div className="space-y-1 pr-2">
|
||||||
{' '}
|
|
||||||
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
|
|
||||||
{!showArchived ? (
|
{!showArchived ? (
|
||||||
activeChats.map((chat) => (
|
activeChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
@@ -520,23 +588,19 @@ function RouteComponent() {
|
|||||||
onClick={() => setActiveChatId(chat.id)}
|
onClick={() => setActiveChatId(chat.id)}
|
||||||
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
|
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
|
||||||
activeChatId === chat.id
|
activeChatId === chat.id
|
||||||
? 'bg-slate-100 font-medium text-slate-900'
|
? 'bg-accent text-foreground font-medium'
|
||||||
: 'text-slate-600 hover:bg-slate-50'
|
: 'text-muted-foreground hover:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* LADO IZQUIERDO: Icono + Texto */}
|
|
||||||
<div
|
<div
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
|
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
|
|
||||||
// donde aparecen los botones
|
|
||||||
maskImage:
|
maskImage:
|
||||||
'linear-gradient(to right, black 70%, transparent 95%)',
|
'linear-gradient(to right, black 70%, transparent 95%)',
|
||||||
WebkitMaskImage:
|
WebkitMaskImage:
|
||||||
'linear-gradient(to right, black 70%, transparent 95%)',
|
'linear-gradient(to right, black 70%, transparent 95%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* pr-12 reserva espacio para los botones absolutos */}
|
|
||||||
<FileText size={16} className="shrink-0 opacity-40" />
|
<FileText size={16} className="shrink-0 opacity-40" />
|
||||||
<TooltipProvider delayDuration={400}>
|
<TooltipProvider delayDuration={400}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -550,7 +614,7 @@ function RouteComponent() {
|
|||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
className={`block truncate outline-none ${
|
className={`block truncate outline-none ${
|
||||||
editingChatId === chat.id
|
editingChatId === chat.id
|
||||||
? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500'
|
? 'bg-background ring-primary max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded px-1 break-all shadow-sm ring-1'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
@@ -571,7 +635,7 @@ function RouteComponent() {
|
|||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (editingChatId === chat.id) {
|
if (editingChatId === chat.id) {
|
||||||
const newTitle =
|
const newTitle =
|
||||||
e.currentTarget.textContent?.trim() || ''
|
e.currentTarget.textContent.trim() || ''
|
||||||
if (newTitle && newTitle !== chat.nombre) {
|
if (newTitle && newTitle !== chat.nombre) {
|
||||||
updateTitleMutation({
|
updateTitleMutation({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
@@ -599,10 +663,9 @@ function RouteComponent() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* LADO DERECHO: Acciones ABSOLUTAS */}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
|
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
|
||||||
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
|
activeChatId === chat.id ? 'bg-accent' : 'bg-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -611,13 +674,13 @@ function RouteComponent() {
|
|||||||
setEditingChatId(chat.id)
|
setEditingChatId(chat.id)
|
||||||
setTimeout(() => editableRef.current?.focus(), 50)
|
setTimeout(() => editableRef.current?.focus(), 50)
|
||||||
}}
|
}}
|
||||||
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
|
className="text-muted-foreground hover:text-primary rounded-md p-1 transition-colors"
|
||||||
>
|
>
|
||||||
<Send size={12} className="rotate-45" />
|
<Send size={12} className="rotate-45" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => archiveChat(e, chat.id)}
|
onClick={(e) => archiveChat(e, chat.id)}
|
||||||
className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
|
className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors"
|
||||||
>
|
>
|
||||||
<Archive size={14} />
|
<Archive size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -625,15 +688,14 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
/* Sección de archivados (Simplificada para mantener consistencia) */
|
|
||||||
<div className="animate-in fade-in slide-in-from-left-2 px-1">
|
<div className="animate-in fade-in slide-in-from-left-2 px-1">
|
||||||
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
<p className="text-muted-foreground mb-2 px-2 text-[10px] font-bold uppercase">
|
||||||
Archivados
|
Archivados
|
||||||
</p>
|
</p>
|
||||||
{archivedChats.map((chat) => (
|
{archivedChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
className="group relative mb-1 flex w-full items-center overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
|
className="bg-muted/50 text-muted-foreground group relative mb-1 flex w-full items-center overflow-hidden rounded-lg px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
|
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
|
||||||
<Archive size={14} className="shrink-0 opacity-30" />
|
<Archive size={14} className="shrink-0 opacity-30" />
|
||||||
@@ -644,7 +706,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => unarchiveChat(e, chat.id)}
|
onClick={(e) => unarchiveChat(e, chat.id)}
|
||||||
className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
|
className="bg-accent hover:text-primary absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -655,22 +717,22 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
{/* PANEL DE CHAT PRINCIPAL */}
|
|
||||||
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
{/* --- PANEL DE CHAT PRINCIPAL --- */}
|
||||||
{/* NUEVO: Barra superior de campos seleccionados */}
|
<div className="border-border/60 bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm md:h-full md:flex-[3]">
|
||||||
<div className="shrink-0 border-b bg-white p-3">
|
<div className="bg-background z-10 shrink-0 border-b p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
<span className="text-muted-foreground text-[10px] font-bold uppercase">
|
||||||
Mejorar con IA
|
Mejorar con IA
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenIA(true)}
|
onClick={() => setOpenIA(true)}
|
||||||
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
|
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition"
|
||||||
>
|
>
|
||||||
<Archive size={14} className="text-slate-500" />
|
<Archive size={14} className="opacity-70" />
|
||||||
Referencias
|
Referencias
|
||||||
{totalReferencias > 0 && (
|
{totalReferencias > 0 && (
|
||||||
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
|
<span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]">
|
||||||
{totalReferencias}
|
{totalReferencias}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -678,22 +740,21 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CONTENIDO DEL CHAT */}
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
<div className="relative min-h-0 flex-1">
|
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||||
{!activeChatId &&
|
{!activeChatId &&
|
||||||
chatMessages.length === 0 &&
|
chatMessages.length === 0 &&
|
||||||
!optimisticMessage ? (
|
!optimisticMessage ? (
|
||||||
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
|
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
|
||||||
<MessageSquarePlus
|
<MessageSquarePlus
|
||||||
size={48}
|
size={48}
|
||||||
className="mb-4 text-slate-300"
|
className="text-muted-foreground/50 mb-4"
|
||||||
/>
|
/>
|
||||||
<h3 className="text-lg font-medium text-slate-900">
|
<h3 className="text-foreground text-lg font-medium">
|
||||||
No hay un chat seleccionado
|
No hay un chat seleccionado
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-muted-foreground text-sm">
|
||||||
Selecciona un chat del historial o crea uno nuevo para
|
Selecciona un chat del historial o crea uno nuevo para
|
||||||
empezar.
|
empezar.
|
||||||
</p>
|
</p>
|
||||||
@@ -703,7 +764,6 @@ function RouteComponent() {
|
|||||||
{chatMessages.map((msg: any) => {
|
{chatMessages.map((msg: any) => {
|
||||||
const isAI = msg.role === 'assistant'
|
const isAI = msg.role === 'assistant'
|
||||||
const isUser = msg.role === 'user'
|
const isUser = msg.role === 'user'
|
||||||
// IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
|
|
||||||
const isProcessing = msg.isProcessing
|
const isProcessing = msg.isProcessing
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -716,47 +776,123 @@ function RouteComponent() {
|
|||||||
<div
|
<div
|
||||||
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
|
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
|
||||||
isUser
|
isUser
|
||||||
? 'rounded-tr-none bg-teal-600 text-white'
|
? 'bg-primary text-primary-foreground rounded-tr-none'
|
||||||
: `rounded-tl-none border bg-white text-slate-700 ${
|
: `bg-card text-card-foreground rounded-tl-none border ${
|
||||||
msg.isRefusal
|
msg.isRefusal
|
||||||
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
|
? 'border-destructive/50 bg-destructive/10 ring-destructive/20 ring-1'
|
||||||
: 'border-slate-100'
|
: 'border-border'
|
||||||
}`
|
}`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Aviso de Refusal */}
|
|
||||||
{msg.isRefusal && (
|
{msg.isRefusal && (
|
||||||
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
|
<div className="text-destructive mb-1 flex items-center gap-1 text-[10px] font-bold uppercase">
|
||||||
<span>Aviso del Asistente</span>
|
<span>Aviso del Asistente</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CONTENIDO CORRECTO: Usamos msg.content */}
|
|
||||||
{isAI && isProcessing ? (
|
{isAI && isProcessing ? (
|
||||||
<div className="flex items-center gap-2 py-1">
|
<div className="flex items-center gap-2 py-1">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
|
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full" />
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
|
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]" />
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
|
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
msg.content // <--- CAMBIO CLAVE
|
msg.content
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recomendaciones */}
|
|
||||||
{isAI && msg.suggestions?.length > 0 && (
|
{isAI && msg.suggestions?.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="border-border/60 bg-muted/50 mt-4 w-full space-y-3 rounded-xl border p-3">
|
||||||
<ImprovementCard
|
<div className="flex items-center justify-between px-1">
|
||||||
suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap
|
<span className="text-muted-foreground text-[10px] font-bold uppercase">
|
||||||
dbMessageId={msg.dbMessageId}
|
Sugerencias de mejora
|
||||||
planId={planId}
|
</span>
|
||||||
currentDatos={data?.datos}
|
{msg.suggestions.some(
|
||||||
activeChatId={activeChatId}
|
(s: any) => !s.applied,
|
||||||
onApplySuccess={(key) =>
|
) && (
|
||||||
removeSelectedField(key)
|
<Button
|
||||||
}
|
variant="ghost"
|
||||||
/>
|
className="text-primary hover:bg-primary/10 h-6 px-2 text-[10px]"
|
||||||
|
onClick={() =>
|
||||||
|
toggleAllFromMessage(msg.suggestions)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{msg.suggestions
|
||||||
|
.filter((s: any) => !s.applied)
|
||||||
|
.every((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
)
|
||||||
|
? 'Desmarcar todo'
|
||||||
|
: 'Seleccionar todo'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg.suggestions.map((sug: any) => (
|
||||||
|
<div key={sug.key} className="flex gap-2">
|
||||||
|
{!sug.applied && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="border-input accent-primary mt-4 h-4 w-4 shrink-0 rounded"
|
||||||
|
checked={selectedImprovements.includes(
|
||||||
|
sug.key,
|
||||||
|
)}
|
||||||
|
onChange={() =>
|
||||||
|
toggleImprovementSelection(sug.key)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<ImprovementCard
|
||||||
|
suggestions={[sug]}
|
||||||
|
dbMessageId={msg.dbMessageId}
|
||||||
|
planId={planId}
|
||||||
|
currentDatos={data?.datos}
|
||||||
|
activeChatId={activeChatId}
|
||||||
|
onApplySuccess={(key) =>
|
||||||
|
removeSelectedField(key)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{msg.suggestions.some((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isSending}
|
||||||
|
className="w-full py-1 text-xs font-bold"
|
||||||
|
onClick={() => {
|
||||||
|
const seleccionadas =
|
||||||
|
msg.suggestions.filter((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
)
|
||||||
|
handleApplyMultiple(
|
||||||
|
seleccionadas,
|
||||||
|
msg.dbMessageId,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 animate-spin"
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Check className="mr-2" size={12} />
|
||||||
|
)}
|
||||||
|
Aplicar seleccionadas (
|
||||||
|
{
|
||||||
|
msg.suggestions.filter((s: any) =>
|
||||||
|
selectedImprovements.includes(s.key),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -766,20 +902,20 @@ function RouteComponent() {
|
|||||||
|
|
||||||
{(isSending || isSyncing) && (
|
{(isSending || isSyncing) && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
||||||
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
|
<Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<Sparkles size={16} className="animate-pulse" />
|
<Sparkles size={16} className="animate-pulse" />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col items-start gap-2">
|
<div className="flex flex-col items-start gap-2">
|
||||||
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
<div className="bg-card border-border rounded-2xl rounded-tl-none border p-4 shadow-sm">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
|
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span>
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
|
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span>
|
||||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
|
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-medium text-slate-400 italic">
|
<span className="text-muted-foreground text-[10px] font-medium italic">
|
||||||
La IA está analizando tu solicitud...
|
La IA está analizando tu solicitud...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -790,9 +926,8 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Botones flotantes de aplicación */}
|
|
||||||
{pendingSuggestion && !isLoading && (
|
{pendingSuggestion && !isLoading && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl">
|
<div className="animate-in fade-in slide-in-from-bottom-2 bg-card border-border absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border p-1.5 shadow-2xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -801,23 +936,19 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<X className="mr-1 h-3 w-3" /> Descartar
|
<X className="mr-1 h-3 w-3" /> Descartar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" className="h-8 rounded-full text-xs">
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
|
|
||||||
>
|
|
||||||
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
|
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
|
{/* INPUT FIJO AL FONDO */}
|
||||||
<div className="shrink-0 border-t bg-white p-4">
|
<div className="bg-background border-border shrink-0 border-t p-4">
|
||||||
<div className="relative mx-auto max-w-4xl">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl">
|
<div className="animate-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full mb-2 w-full rounded-xl border shadow-2xl">
|
||||||
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
<div className="bg-muted text-muted-foreground border-b px-3 py-2 text-[10px] font-bold uppercase">
|
||||||
Resultados para "{filterQuery}"
|
Resultados para "{filterQuery}"
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto p-1">
|
<div className="max-h-64 overflow-y-auto p-1">
|
||||||
@@ -828,8 +959,8 @@ function RouteComponent() {
|
|||||||
onClick={() => toggleField(field)}
|
onClick={() => toggleField(field)}
|
||||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
index === 0
|
index === 0
|
||||||
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
|
? 'bg-primary/10 text-primary ring-primary/30 ring-1 ring-inset'
|
||||||
: 'hover:bg-slate-50'
|
: 'hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
@@ -841,7 +972,7 @@ function RouteComponent() {
|
|||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="p-3 text-center text-xs text-slate-400">
|
<div className="text-muted-foreground p-3 text-center text-xs">
|
||||||
No hay coincidencias
|
No hay coincidencias
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -849,20 +980,18 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CONTENEDOR DEL INPUT TRANSFORMADO */}
|
<div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1">
|
||||||
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
|
|
||||||
{/* 1. Visualización de campos dentro del input ) */}
|
|
||||||
{selectedFields.length > 0 && (
|
{selectedFields.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 px-2 pt-1">
|
<div className="flex flex-wrap gap-2 px-2 pt-1">
|
||||||
{selectedFields.map((field) => (
|
{selectedFields.map((field) => (
|
||||||
<div
|
<div
|
||||||
key={field.key}
|
key={field.key}
|
||||||
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800"
|
className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-semibold"
|
||||||
>
|
>
|
||||||
<span className="opacity-70">Campo:</span> {field.label}
|
<span className="opacity-70">Campo:</span> {field.label}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleField(field)}
|
onClick={() => toggleField(field)}
|
||||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200"
|
className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
@@ -871,7 +1000,6 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 2. Área de escritura */}
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
@@ -890,7 +1018,6 @@ function RouteComponent() {
|
|||||||
setFilterQuery('')
|
setFilterQuery('')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Si el usuario borra y el input está vacío, eliminar el último campo
|
|
||||||
if (
|
if (
|
||||||
e.key === 'Backspace' &&
|
e.key === 'Backspace' &&
|
||||||
input === '' &&
|
input === '' &&
|
||||||
@@ -918,7 +1045,7 @@ function RouteComponent() {
|
|||||||
isSending || (!input.trim() && selectedFields.length === 0)
|
isSending || (!input.trim() && selectedFields.length === 0)
|
||||||
}
|
}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
|
className="mb-1 h-9 w-9 shrink-0"
|
||||||
>
|
>
|
||||||
{isSending ? (
|
{isSending ? (
|
||||||
<Loader2 className="animate-spin" size={16} />
|
<Loader2 className="animate-spin" size={16} />
|
||||||
@@ -931,44 +1058,100 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* PANEL LATERAL */}
|
|
||||||
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS --- */}
|
||||||
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex">
|
||||||
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
<h4 className="text-foreground flex items-center gap-2 text-left text-sm font-bold">
|
||||||
|
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 p-1">
|
||||||
{PRESETS.map((preset) => (
|
{PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
onClick={() => handleSend(preset.prompt)}
|
onClick={() => handleSend(preset.prompt)}
|
||||||
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
|
className="bg-card hover:border-primary hover:bg-primary/5 group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm shadow-sm transition-all"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
|
<div className="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors">
|
||||||
<preset.icon size={16} />
|
<preset.icon size={16} />
|
||||||
</div>
|
</div>
|
||||||
<span className="leading-tight font-medium text-slate-700">
|
<span className="text-foreground leading-tight font-medium">
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
|
||||||
|
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
|
||||||
|
<DrawerContent className="h-[80vh] p-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
createNewChat()
|
||||||
|
setIsHistoryOpen(false)
|
||||||
|
}}
|
||||||
|
className="mb-4 w-full"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
|
||||||
|
</Button>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<p className="text-muted-foreground mb-4 text-xs font-bold uppercase">
|
||||||
|
Historial Reciente
|
||||||
|
</p>
|
||||||
|
{activeChats.map((chat) => (
|
||||||
|
<div
|
||||||
|
key={chat.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveChatId(chat.id)
|
||||||
|
setIsHistoryOpen(false)
|
||||||
|
}}
|
||||||
|
className="border-border border-b p-3 text-sm"
|
||||||
|
>
|
||||||
|
{chat.nombre || 'Chat sin nombre'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
|
||||||
|
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
|
||||||
|
<DrawerContent className="h-[60vh] p-4">
|
||||||
|
<h4 className="mb-4 flex items-center gap-2 font-bold">
|
||||||
|
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => {
|
||||||
|
handleSend(preset.prompt)
|
||||||
|
setIsActionsOpen(false)
|
||||||
|
}}
|
||||||
|
className="border-border flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
|
||||||
|
>
|
||||||
|
<preset.icon size={16} />
|
||||||
|
<span>{preset.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Drawer open={openIA} onOpenChange={setOpenIA}>
|
<Drawer open={openIA} onOpenChange={setOpenIA}>
|
||||||
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
|
<DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl">
|
||||||
{/* Cabecera más compacta */}
|
<div className="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3">
|
||||||
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
|
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
|
||||||
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
|
|
||||||
Referencias para la IA
|
Referencias para la IA
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenIA(false)}
|
onClick={() => setOpenIA(false)}
|
||||||
className="text-slate-400 transition-colors hover:text-slate-600"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contenido con scroll interno */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<ReferenciasParaIA
|
<ReferenciasParaIA
|
||||||
selectedArchivoIds={selectedArchivoIds}
|
selectedArchivoIds={selectedArchivoIds}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
useNavigate,
|
useNavigate,
|
||||||
useLocation,
|
useLocation,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
// import confetti from 'canvas-confetti'
|
|
||||||
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
|
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { usePlan, useUpdatePlanFields } from '@/data'
|
import { usePlan, useUpdatePlanFields } from '@/data'
|
||||||
|
|
||||||
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/')({
|
||||||
component: DatosGeneralesPage,
|
component: DatosGeneralesPage,
|
||||||
})
|
})
|
||||||
@@ -34,21 +32,20 @@ function DatosGeneralesPage() {
|
|||||||
const { planId } = Route.useParams()
|
const { planId } = Route.useParams()
|
||||||
const { data, isLoading } = usePlan(planId)
|
const { data, isLoading } = usePlan(planId)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
// Inicializamos campos como un arreglo vacío
|
|
||||||
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
|
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [editValue, setEditValue] = useState('')
|
const [editValue, setEditValue] = useState('')
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const updatePlan = useUpdatePlanFields()
|
const updatePlan = useUpdatePlanFields()
|
||||||
// Confetti al llegar desde creación
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.state.showConfetti) {
|
if (location.state.showConfetti) {
|
||||||
lateralConfetti()
|
lateralConfetti()
|
||||||
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
|
window.history.replaceState({}, document.title)
|
||||||
}
|
}
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
// Efecto para transformar data?.datos en el arreglo de campos
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const definicion = data?.estructuras_plan?.definicion as any
|
const definicion = data?.estructuras_plan?.definicion as any
|
||||||
const properties = definicion?.properties
|
const properties = definicion?.properties
|
||||||
@@ -59,18 +56,13 @@ function DatosGeneralesPage() {
|
|||||||
if (properties && typeof properties === 'object') {
|
if (properties && typeof properties === 'object') {
|
||||||
let keys = Object.keys(properties)
|
let keys = Object.keys(properties)
|
||||||
|
|
||||||
// Ordenar llaves basado en la lista "required" si existe
|
|
||||||
if (Array.isArray(requiredOrder)) {
|
if (Array.isArray(requiredOrder)) {
|
||||||
keys = keys.sort((a, b) => {
|
keys = keys.sort((a, b) => {
|
||||||
const indexA = requiredOrder.indexOf(a)
|
const indexA = requiredOrder.indexOf(a)
|
||||||
const indexB = requiredOrder.indexOf(b)
|
const indexB = requiredOrder.indexOf(b)
|
||||||
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
|
|
||||||
if (indexA !== -1 && indexB === -1) return -1
|
if (indexA !== -1 && indexB === -1) return -1
|
||||||
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
|
|
||||||
if (indexA === -1 && indexB !== -1) return 1
|
if (indexA === -1 && indexB !== -1) return 1
|
||||||
// Si ambos están, comparar índices
|
|
||||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB
|
if (indexA !== -1 && indexB !== -1) return indexA - indexB
|
||||||
// Ninguno en la lista, mantener orden relativo
|
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -108,18 +100,14 @@ function DatosGeneralesPage() {
|
|||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
// 3. Manejadores de acciones (Ahora como funciones locales)
|
|
||||||
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
|
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
|
||||||
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
|
|
||||||
if (editingId && editingId !== nuevoCampo.id) {
|
if (editingId && editingId !== nuevoCampo.id) {
|
||||||
const campoAnterior = campos.find((c) => c.id === editingId)
|
const campoAnterior = campos.find((c) => c.id === editingId)
|
||||||
if (campoAnterior && editValue !== campoAnterior.value) {
|
if (campoAnterior && editValue !== campoAnterior.value) {
|
||||||
// Solo guardamos si el valor realmente cambió
|
|
||||||
ejecutarGuardadoSilencioso(campoAnterior, editValue)
|
ejecutarGuardadoSilencioso(campoAnterior, editValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ABRIMOS EL NUEVO CAMPO
|
|
||||||
setEditingId(nuevoCampo.id)
|
setEditingId(nuevoCampo.id)
|
||||||
setEditValue(nuevoCampo.value)
|
setEditValue(nuevoCampo.value)
|
||||||
}
|
}
|
||||||
@@ -128,7 +116,7 @@ function DatosGeneralesPage() {
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setEditValue('')
|
setEditValue('')
|
||||||
}
|
}
|
||||||
// Función auxiliar para procesar los datos (fuera o dentro del componente)
|
|
||||||
const prepararDatosActualizados = (
|
const prepararDatosActualizados = (
|
||||||
data: any,
|
data: any,
|
||||||
campo: DatosGeneralesField,
|
campo: DatosGeneralesField,
|
||||||
@@ -167,7 +155,6 @@ function DatosGeneralesPage() {
|
|||||||
patch: { datos: datosActualizados },
|
patch: { datos: datosActualizados },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Actualizar UI localmente
|
|
||||||
setCampos((prev) =>
|
setCampos((prev) =>
|
||||||
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
|
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
|
||||||
)
|
)
|
||||||
@@ -185,13 +172,11 @@ function DatosGeneralesPage() {
|
|||||||
currentValue !== null &&
|
currentValue !== null &&
|
||||||
'description' in currentValue
|
'description' in currentValue
|
||||||
) {
|
) {
|
||||||
// Caso 1: objeto con description
|
|
||||||
newValue = {
|
newValue = {
|
||||||
...currentValue,
|
...currentValue,
|
||||||
description: editValue,
|
description: editValue,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Caso 2: valor plano (string, number, etc)
|
|
||||||
newValue = editValue
|
newValue = editValue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +192,6 @@ function DatosGeneralesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// UI optimista
|
|
||||||
setCampos((prev) =>
|
setCampos((prev) =>
|
||||||
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
|
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
|
||||||
)
|
)
|
||||||
@@ -217,19 +201,20 @@ function DatosGeneralesPage() {
|
|||||||
setEditValue('')
|
setEditValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (clave: string) => {
|
const handleIARequest = (campo: DatosGeneralesField) => {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/planes/$planId/iaplan',
|
to: '/planes/$planId/iaplan',
|
||||||
params: {
|
params: {
|
||||||
planId: planId, // o dinámico
|
planId: planId,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
campo_edit: clave,
|
campo_edit: campo.clave,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
|
<div className="animate-in fade-in duration-500">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-foreground text-lg font-semibold">
|
<h2 className="text-foreground text-lg font-semibold">
|
||||||
Datos Generales del Plan
|
Datos Generales del Plan
|
||||||
@@ -246,19 +231,19 @@ function DatosGeneralesPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={campo.id}
|
key={campo.id}
|
||||||
className={`rounded-xl border transition-all ${
|
className={`bg-card rounded-xl border transition-all ${
|
||||||
isEditing
|
isEditing
|
||||||
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
|
? 'border-primary ring-primary/20 shadow-lg ring-2'
|
||||||
: 'bg-white hover:shadow-md'
|
: 'hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header de la Card */}
|
{/* Header de la Card */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
|
<div className="bg-muted/30 flex items-center justify-between border-b px-5 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<h3 className="cursor-help text-sm font-medium text-slate-700">
|
<h3 className="text-foreground cursor-help text-sm font-medium">
|
||||||
{campo.label}
|
{campo.label}
|
||||||
</h3>
|
</h3>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -268,7 +253,7 @@ function DatosGeneralesPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{campo.requerido && (
|
{campo.requerido && (
|
||||||
<span className="text-xs text-red-500">*</span>
|
<span className="text-destructive text-xs">*</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,7 +264,7 @@ function DatosGeneralesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-teal-600"
|
className="text-primary hover:text-primary/90 h-8 w-8"
|
||||||
onClick={() => handleIARequest(campo)}
|
onClick={() => handleIARequest(campo)}
|
||||||
>
|
>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
@@ -293,7 +278,7 @@ function DatosGeneralesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="text-muted-foreground hover:text-foreground h-8 w-8"
|
||||||
onClick={() => handleEdit(campo)}
|
onClick={() => handleEdit(campo)}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
@@ -324,11 +309,7 @@ function DatosGeneralesPage() {
|
|||||||
>
|
>
|
||||||
<X size={14} className="mr-1" /> Cancelar
|
<X size={14} className="mr-1" /> Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" onClick={() => handleSave(campo)}>
|
||||||
size="sm"
|
|
||||||
className="bg-teal-600 hover:bg-teal-700"
|
|
||||||
onClick={() => handleSave(campo)}
|
|
||||||
>
|
|
||||||
<Check size={14} className="mr-1" /> Guardar
|
<Check size={14} className="mr-1" /> Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,12 +317,12 @@ function DatosGeneralesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="min-h-25">
|
<div className="min-h-25">
|
||||||
{campo.value ? (
|
{campo.value ? (
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
<div className="text-muted-foreground text-sm leading-relaxed">
|
||||||
{campo.tipo === 'lista' ? (
|
{campo.tipo === 'lista' ? (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{campo.value.split('\n').map((item, i) => (
|
{campo.value.split('\n').map((item, i) => (
|
||||||
<li key={i} className="flex gap-2">
|
<li key={i} className="flex gap-2">
|
||||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
|
<span className="bg-primary mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" />
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -351,7 +332,7 @@ function DatosGeneralesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
<div className="text-muted-foreground/70 flex items-center gap-2 text-sm">
|
||||||
<AlertCircle size={14} />
|
<AlertCircle size={14} />
|
||||||
<span>Sin contenido.</span>
|
<span>Sin contenido.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,96 +268,120 @@ 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 max-w-7xl px-6 py-10">
|
<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="border-b bg-white">
|
<nav className="sticky top-0 z-20 border-b bg-white">
|
||||||
<div className="mx-auto max-w-7xl px-6">
|
<div className="mx-auto p-4 py-2 md:px-6 lg:px-8">
|
||||||
<div className="flex justify-center gap-8">
|
{/* 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' },
|
||||||
@@ -241,11 +405,12 @@ function AsignaturaLayout() {
|
|||||||
}
|
}
|
||||||
from="/planes/$planId/asignaturas/$asignaturaId"
|
from="/planes/$planId/asignaturas/$asignaturaId"
|
||||||
params={{ planId, asignaturaId }}
|
params={{ planId, asignaturaId }}
|
||||||
className={`border-b-2 py-3 text-sm font-medium ${
|
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>
|
||||||
@@ -255,7 +420,7 @@ function AsignaturaLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
<div className="mx-auto p-4 py-8 md:px-6 lg:px-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import BarraBusqueda from '@/components/planes/BarraBusqueda'
|
|||||||
import Filtro from '@/components/planes/Filtro'
|
import Filtro from '@/components/planes/Filtro'
|
||||||
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
||||||
// Hooks y Utils (ajusta las rutas de importación)
|
// Hooks y Utils (ajusta las rutas de importación)
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
|
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
|
||||||
import { getIconByName } from '@/features/planes/utils/icon-utils'
|
import { getIconByName } from '@/features/planes/utils/icon-utils'
|
||||||
|
|
||||||
@@ -101,13 +102,20 @@ function RouteComponent() {
|
|||||||
setPage(0) // Resetear página al buscar
|
setPage(0) // Resetear página al buscar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deshabilitar el botón 'Limpiar' si no hay filtros distintos al valor por defecto
|
||||||
|
const isClearDisabled =
|
||||||
|
cleanSearchTerm === '' &&
|
||||||
|
facultadSel === 'todas' &&
|
||||||
|
carreraSel === 'todas' &&
|
||||||
|
estadoSel === 'todos'
|
||||||
|
|
||||||
// Renderizado condicional básico
|
// Renderizado condicional básico
|
||||||
if (isError)
|
if (isError)
|
||||||
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background min-h-screen w-full">
|
<main className="bg-background min-h-screen w-full">
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
<div className="mx-auto flex w-full flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||||
<div className="flex flex-col gap-4 lg:col-span-3">
|
<div className="flex flex-col gap-4 lg:col-span-3">
|
||||||
{/* Header y Botón Nuevo */}
|
{/* Header y Botón Nuevo */}
|
||||||
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
@@ -124,16 +132,16 @@ function RouteComponent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('planId')
|
console.log('planId')
|
||||||
|
|
||||||
navigate({ to: '/planes/nuevo', resetScroll: false })
|
navigate({ to: '/planes/nuevo', resetScroll: false })
|
||||||
}}
|
}}
|
||||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
className="shadow-md"
|
||||||
>
|
>
|
||||||
<Icons.Plus /> Nuevo plan de estudios
|
<Icons.Plus /> Nuevo plan de estudios
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barra de Filtros */}
|
{/* Barra de Filtros */}
|
||||||
@@ -181,13 +189,15 @@ function RouteComponent() {
|
|||||||
placeholder="Estado"
|
placeholder="Estado"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors"
|
disabled={isClearDisabled}
|
||||||
|
className={`shadow-md`}
|
||||||
>
|
>
|
||||||
<Icons.X className="h-4 w-4" /> Limpiar
|
<Icons.X className="h-4 w-4" /> Limpiar
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,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
|
||||||
@@ -225,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({
|
||||||
|
|||||||
@@ -335,3 +335,26 @@ body {
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Utilidades locales (sin romper el design system)
|
||||||
|
|
||||||
|
Intención:
|
||||||
|
- Evitar estilos inline en JSX para assets puntuales del proyecto
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-6
@@ -31,12 +31,7 @@ export interface Carrera {
|
|||||||
facultadId: string
|
facultadId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LineaCurricular {
|
export type LineaCurricular = Tables<'lineas_plan'>
|
||||||
id: string
|
|
||||||
nombre: string
|
|
||||||
orden: number
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Asignatura {
|
export interface Asignatura {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export type Database = {
|
|||||||
orden_celda: number | null
|
orden_celda: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
prerrequisito_asignatura_id: string | null
|
prerrequisito_asignatura_id: string | null
|
||||||
|
search_vector: unknown
|
||||||
tipo: Database['public']['Enums']['tipo_asignatura']
|
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -181,6 +182,7 @@ export type Database = {
|
|||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
prerrequisito_asignatura_id?: string | null
|
prerrequisito_asignatura_id?: string | null
|
||||||
|
search_vector?: unknown
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -207,6 +209,7 @@ export type Database = {
|
|||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id?: string
|
plan_estudio_id?: string
|
||||||
prerrequisito_asignatura_id?: string | null
|
prerrequisito_asignatura_id?: string | null
|
||||||
|
search_vector?: unknown
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -830,6 +833,7 @@ export type Database = {
|
|||||||
Row: {
|
Row: {
|
||||||
actualizado_en: string
|
actualizado_en: string
|
||||||
area: string | null
|
area: string | null
|
||||||
|
color: string | null
|
||||||
creado_en: string
|
creado_en: string
|
||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
@@ -839,6 +843,7 @@ export type Database = {
|
|||||||
Insert: {
|
Insert: {
|
||||||
actualizado_en?: string
|
actualizado_en?: string
|
||||||
area?: string | null
|
area?: string | null
|
||||||
|
color?: string | null
|
||||||
creado_en?: string
|
creado_en?: string
|
||||||
id?: string
|
id?: string
|
||||||
nombre: string
|
nombre: string
|
||||||
@@ -848,6 +853,7 @@ export type Database = {
|
|||||||
Update: {
|
Update: {
|
||||||
actualizado_en?: string
|
actualizado_en?: string
|
||||||
area?: string | null
|
area?: string | null
|
||||||
|
color?: string | null
|
||||||
creado_en?: string
|
creado_en?: string
|
||||||
id?: string
|
id?: string
|
||||||
nombre?: string
|
nombre?: string
|
||||||
@@ -1393,6 +1399,35 @@ export type Database = {
|
|||||||
Args: { p_append: Json; p_id: string }
|
Args: { p_append: Json; p_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
|
build_asignaturas_prefix_tsquery: {
|
||||||
|
Args: { p_search: string }
|
||||||
|
Returns: unknown
|
||||||
|
}
|
||||||
|
recalcular_vectores_asignaturas: { Args: never; Returns: undefined }
|
||||||
|
search_asignaturas: {
|
||||||
|
Args: {
|
||||||
|
p_carrera_id?: string
|
||||||
|
p_facultad_id?: string
|
||||||
|
p_limit?: number
|
||||||
|
p_offset?: number
|
||||||
|
p_plan_estudio_id?: string
|
||||||
|
p_search?: string
|
||||||
|
}
|
||||||
|
Returns: Array<{
|
||||||
|
codigo: string
|
||||||
|
contenido_tematico: Json
|
||||||
|
creditos: number
|
||||||
|
datos: Json
|
||||||
|
estado: Database['public']['Enums']['estado_asignatura']
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
numero_ciclo: number
|
||||||
|
plan_estudio_id: string
|
||||||
|
rank: number
|
||||||
|
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||||
|
total_count: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
suma_porcentajes: { Args: { '': Json }; Returns: number }
|
suma_porcentajes: { Args: { '': Json }; Returns: number }
|
||||||
unaccent: { Args: { '': string }; Returns: string }
|
unaccent: { Args: { '': string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Convierte HSL a Hexadecimal
|
||||||
|
function hslToHex(h: number, s: number, l: number): string {
|
||||||
|
l /= 100
|
||||||
|
const a = (s * Math.min(l, 1 - l)) / 100
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12
|
||||||
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||||
|
return Math.round(255 * color)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')
|
||||||
|
}
|
||||||
|
return `#${f(0)}${f(8)}${f(4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un color contrastante.
|
||||||
|
* @param prevHue El tono (0-360) del color anterior. Null si es el primero.
|
||||||
|
* @returns Objeto con el hex y el nuevo hue para guardar como referencia.
|
||||||
|
*/
|
||||||
|
export function generarColorContrastante(prevHue: number | null = null) {
|
||||||
|
let newHue: number
|
||||||
|
|
||||||
|
if (prevHue === null) {
|
||||||
|
// Primer color: completamente al azar
|
||||||
|
newHue = Math.floor(Math.random() * 360)
|
||||||
|
} else {
|
||||||
|
// Siguientes: Salto aleatorio entre 130° y 230° para forzar contraste
|
||||||
|
const salto = 130 + Math.floor(Math.random() * 100)
|
||||||
|
newHue = (prevHue + salto) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mantenemos saturación al 70% y luminosidad al 50% para colores vivos pero no fosforescentes
|
||||||
|
const hex = hslToHex(newHue, 70, 50)
|
||||||
|
|
||||||
|
return { hex, hue: newHue }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user