Compare commits
1 Commits
main
...
issue/212-
| Author | SHA1 | Date | |
|---|---|---|---|
| ed318fa67b |
4
bun.lock
4
bun.lock
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ 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,
|
||||||
@@ -21,7 +14,6 @@ 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'
|
||||||
|
|
||||||
@@ -72,12 +64,8 @@ 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()
|
||||||
|
|
||||||
@@ -98,54 +86,16 @@ 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(() => {
|
||||||
console.log(requisitosFormateados)
|
|
||||||
|
|
||||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
}, [asignaturaApi, requisitosFormateados])
|
}, [asignaturaApi])
|
||||||
|
|
||||||
return (
|
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
||||||
<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
|
||||||
}) {
|
}) {
|
||||||
@@ -320,19 +270,18 @@ function DatosGenerales({
|
|||||||
<InfoCard
|
<InfoCard
|
||||||
title="Requisitos y Seriación"
|
title="Requisitos y Seriación"
|
||||||
type="requirements"
|
type="requirements"
|
||||||
initialContent={pre}
|
initialContent={[
|
||||||
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
{
|
||||||
availableSubjects={
|
type: 'Pre-requisito',
|
||||||
availableSubjects?.filter((a) => a.id !== asignaturaId) || []
|
code: 'PA-301',
|
||||||
}
|
name: 'Programación Avanzada',
|
||||||
onPersist={({ value }) => {
|
|
||||||
updateAsignatura.mutate({
|
|
||||||
asignaturaId,
|
|
||||||
patch: {
|
|
||||||
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
|
|
||||||
},
|
},
|
||||||
})
|
{
|
||||||
}}
|
type: 'Co-requisito',
|
||||||
|
code: 'MAT-201',
|
||||||
|
name: 'Matemáticas Discretas',
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tarjeta de Evaluación */}
|
{/* Tarjeta de Evaluación */}
|
||||||
@@ -372,7 +321,6 @@ 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({
|
||||||
@@ -389,7 +337,6 @@ 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)
|
||||||
@@ -407,8 +354,7 @@ function InfoCard({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData(initialContent)
|
setData(initialContent)
|
||||||
setTempText(initialContent)
|
setTempText(initialContent)
|
||||||
console.log(data)
|
|
||||||
console.log(initialContent)
|
|
||||||
if (type === 'evaluation') {
|
if (type === 'evaluation') {
|
||||||
const raw = Array.isArray(initialContent) ? initialContent : []
|
const raw = Array.isArray(initialContent) ? initialContent : []
|
||||||
const rows: Array<CriterioEvaluacionRowDraft> = raw
|
const rows: Array<CriterioEvaluacionRowDraft> = raw
|
||||||
@@ -451,8 +397,6 @@ function InfoCard({
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
console.log('clave, valor:', clave, String(tempText ?? ''))
|
console.log('clave, valor:', clave, String(tempText ?? ''))
|
||||||
console.log(clave)
|
|
||||||
console.log(tempText)
|
|
||||||
|
|
||||||
if (type === 'evaluation') {
|
if (type === 'evaluation') {
|
||||||
const cleaned: Array<CriterioEvaluacionRow> = []
|
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||||
@@ -483,25 +427,6 @@ function InfoCard({
|
|||||||
void onPersist?.({ type, clave, value: cleaned })
|
void onPersist?.({ type, clave, value: cleaned })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type === 'requirements') {
|
|
||||||
console.log('entre aqui ')
|
|
||||||
|
|
||||||
// Si tempText es un array y tiene elementos, tomamos el ID del primero
|
|
||||||
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
|
|
||||||
const prerequisiteId =
|
|
||||||
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)
|
||||||
@@ -621,52 +546,7 @@ function InfoCard({
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Condicionales de edición según el tipo */}
|
{type === 'evaluation' ? (
|
||||||
{type === 'requirements' ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-xs font-medium text-slate-500">
|
|
||||||
Materia de Seriación
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={tempText?.[0]?.id || 'none'}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
const selected = availableSubjects?.find(
|
|
||||||
(s) => s.id === val,
|
|
||||||
)
|
|
||||||
if (val === 'none' || !selected) {
|
|
||||||
console.log('guardando')
|
|
||||||
|
|
||||||
setTempText([])
|
|
||||||
} else {
|
|
||||||
console.log('hola')
|
|
||||||
|
|
||||||
setTempText([
|
|
||||||
{
|
|
||||||
id: selected.id,
|
|
||||||
type: 'Pre-requisito',
|
|
||||||
code: selected.codigo,
|
|
||||||
name: selected.nombre,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecciona una materia" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">
|
|
||||||
Ninguna (Sin seriación)
|
|
||||||
</SelectItem>
|
|
||||||
{availableSubjects?.map((asig) => (
|
|
||||||
<SelectItem key={asig.id} value={asig.id}>
|
|
||||||
{asig.codigo} - {asig.nombre}
|
|
||||||
</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) => (
|
||||||
@@ -688,36 +568,85 @@ 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, porcentaje: raw } : r,
|
r.id === row.id
|
||||||
)
|
? {
|
||||||
const total = next.reduce(
|
id: r.id,
|
||||||
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
criterio: r.criterio,
|
||||||
0,
|
porcentaje: raw,
|
||||||
|
}
|
||||||
|
: 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>
|
||||||
@@ -727,15 +656,22 @@ function InfoCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
className={
|
||||||
|
'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,
|
||||||
{
|
{
|
||||||
@@ -744,7 +680,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>
|
||||||
@@ -758,15 +694,28 @@ 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)
|
||||||
// Lógica de reset si es necesario...
|
if (type === 'evaluation') {
|
||||||
|
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
|
||||||
@@ -782,7 +731,6 @@ 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 ? (
|
||||||
@@ -791,7 +739,9 @@ 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' && <EvaluationView items={data} />}
|
{type === 'evaluation' && (
|
||||||
|
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -20,15 +20,12 @@ import { useState, useEffect, useRef, useMemo } from 'react'
|
|||||||
|
|
||||||
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
|
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
|
||||||
|
|
||||||
|
import type { IASugerencia } from '@/types/asignatura'
|
||||||
|
|
||||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||||
Drawer,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerPortal,
|
|
||||||
} from '@/components/ui/drawer'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +50,16 @@ interface SelectedField {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IAAsignaturaTab() {
|
interface IAAsignaturaTabProps {
|
||||||
|
asignatura?: Record<string, any>
|
||||||
|
onAcceptSuggestion: (sugerencia: IASugerencia) => void
|
||||||
|
onRejectSuggestion: (messageId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IAAsignaturaTab({
|
||||||
|
onAcceptSuggestion,
|
||||||
|
onRejectSuggestion,
|
||||||
|
}: IAAsignaturaTabProps) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
@@ -69,7 +75,6 @@ export function IAAsignaturaTab() {
|
|||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
|
||||||
|
|
||||||
// --- DATA QUERIES ---
|
// --- DATA QUERIES ---
|
||||||
const { data: datosGenerales } = useSubject(asignaturaId)
|
const { data: datosGenerales } = useSubject(asignaturaId)
|
||||||
@@ -136,7 +141,7 @@ export function IAAsignaturaTab() {
|
|||||||
const dynamicFields = datosGenerales?.datos
|
const dynamicFields = datosGenerales?.datos
|
||||||
? Object.keys(datosGenerales.datos).map((key) => {
|
? Object.keys(datosGenerales.datos).map((key) => {
|
||||||
const estructuraProps =
|
const estructuraProps =
|
||||||
datosGenerales.estructuras_asignatura?.definicion?.properties || {}
|
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label:
|
label:
|
||||||
@@ -229,38 +234,12 @@ export function IAAsignaturaTab() {
|
|||||||
}
|
}
|
||||||
}, [activeChats, loadingConv])
|
}, [activeChats, loadingConv])
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const value = e.target.value
|
|
||||||
const selectionStart = e.target.selectionStart // Posición del cursor
|
|
||||||
setInput(value)
|
|
||||||
|
|
||||||
// Buscamos si el carácter anterior al cursor es ':'
|
|
||||||
const lastChar = value.slice(selectionStart - 1, selectionStart)
|
|
||||||
|
|
||||||
if (lastChar === ':') {
|
|
||||||
setShowSuggestions(true)
|
|
||||||
} else if (!value.includes(':')) {
|
|
||||||
// Si borran todos los dos puntos, cerramos
|
|
||||||
setShowSuggestions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredFields = useMemo(() => {
|
const filteredFields = useMemo(() => {
|
||||||
if (!showSuggestions || !input) return availableFields
|
if (!showSuggestions) return availableFields
|
||||||
|
|
||||||
// 1. Encontrar el ":" más cercano a la IZQUIERDA del cursor
|
|
||||||
// Usamos una posición de referencia (si no tienes ref, usaremos el final del string,
|
|
||||||
// pero para mayor precisión lo ideal es usar e.target.selectionStart en el onChange)
|
|
||||||
|
|
||||||
|
// Extraemos lo que hay después del último ':' para filtrar
|
||||||
const lastColonIndex = input.lastIndexOf(':')
|
const lastColonIndex = input.lastIndexOf(':')
|
||||||
if (lastColonIndex === -1) return availableFields
|
const query = input.slice(lastColonIndex + 1).toLowerCase()
|
||||||
|
|
||||||
// 2. Extraer solo el fragmento de "búsqueda"
|
|
||||||
// Cortamos desde el ":" hasta el final, y luego tomamos solo la primera palabra
|
|
||||||
const textAfterColon = input.slice(lastColonIndex + 1)
|
|
||||||
const query = textAfterColon.split(/\s/)[0].toLowerCase() // Se detiene al encontrar un espacio
|
|
||||||
|
|
||||||
if (!query) return availableFields
|
|
||||||
|
|
||||||
return availableFields.filter(
|
return availableFields.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
@@ -280,28 +259,24 @@ export function IAAsignaturaTab() {
|
|||||||
|
|
||||||
// 3. Función para insertar el campo y limpiar el prompt
|
// 3. Función para insertar el campo y limpiar el prompt
|
||||||
const handleSelectField = (field: SelectedField) => {
|
const handleSelectField = (field: SelectedField) => {
|
||||||
|
// 1. Agregamos al array de objetos (para tu lógica de API)
|
||||||
if (!selectedFields.find((f) => f.key === field.key)) {
|
if (!selectedFields.find((f) => f.key === field.key)) {
|
||||||
setSelectedFields((prev) => [...prev, field])
|
setSelectedFields((prev) => [...prev, field])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Lógica de autocompletado en el texto
|
||||||
const lastColonIndex = input.lastIndexOf(':')
|
const lastColonIndex = input.lastIndexOf(':')
|
||||||
if (lastColonIndex !== -1) {
|
if (lastColonIndex !== -1) {
|
||||||
const parteAntesDelColon = input.slice(0, lastColonIndex)
|
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
|
||||||
|
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
|
||||||
// Buscamos si hay texto después de la palabra que estamos escribiendo
|
|
||||||
const textoDespuesDelColon = input.slice(lastColonIndex + 1)
|
|
||||||
const espacioIndex = textoDespuesDelColon.indexOf(' ')
|
|
||||||
|
|
||||||
// Si hay un espacio, guardamos lo que sigue. Si no, es el final del texto.
|
|
||||||
const parteRestante =
|
|
||||||
espacioIndex !== -1 ? textoDespuesDelColon.slice(espacioIndex) : ''
|
|
||||||
|
|
||||||
// Reconstruimos: [Antes] + [Label] + [Lo que ya estaba después]
|
|
||||||
const nuevoTexto = `${parteAntesDelColon}${field.label}${parteRestante}`
|
|
||||||
setInput(nuevoTexto)
|
setInput(nuevoTexto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Cerramos el buscador y devolvemos el foco al textarea
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
|
|
||||||
|
// Opcional: Si tienes una ref del textarea, puedes hacer:
|
||||||
|
// textareaRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveName = (id: string) => {
|
const handleSaveName = (id: string) => {
|
||||||
@@ -380,35 +355,10 @@ export function IAAsignaturaTab() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full overflow-hidden bg-white">
|
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
||||||
<div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
|
{/* PANEL IZQUIERDO */}
|
||||||
<Button
|
<div className="flex w-64 flex-col border-r pr-4">
|
||||||
variant="ghost"
|
<div className="mb-4 flex items-center justify-between px-2">
|
||||||
size="sm"
|
|
||||||
className="text-slate-600"
|
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
|
||||||
>
|
|
||||||
<History size={18} className="mr-2" /> Historial
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<span className="text-[9px] font-bold tracking-wider text-teal-600 uppercase">
|
|
||||||
Asistente
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-slate-600"
|
|
||||||
onClick={() => setOpenIA(true)} // O el drawer de acciones/referencias
|
|
||||||
>
|
|
||||||
<FileText size={18} className="mr-2 text-teal-600" /> Referencias
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */}
|
|
||||||
<aside className="hidden h-full w-64 shrink-0 flex-col border-r bg-white pr-4 md:flex">
|
|
||||||
<div className="mb-4 flex items-center justify-between px-2 pt-4">
|
|
||||||
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
|
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
|
||||||
<History size={14} /> Historial
|
<History size={14} /> Historial
|
||||||
</h2>
|
</h2>
|
||||||
@@ -428,19 +378,25 @@ export function IAAsignaturaTab() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveChatId(undefined)
|
setActiveChatId(undefined)
|
||||||
|
hasInitialSelected.current = true
|
||||||
setIsCreatingNewChat(true)
|
setIsCreatingNewChat(true)
|
||||||
setInput('')
|
setInput('')
|
||||||
setSelectedFields([])
|
setSelectedFields([])
|
||||||
|
|
||||||
|
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
|
||||||
queryClient.setQueryData(['subject-messages', undefined], [])
|
queryClient.setQueryData(['subject-messages', undefined], [])
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 text-slate-600 hover:border-teal-500 hover:bg-teal-50/50"
|
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 hover:border-teal-500"
|
||||||
>
|
>
|
||||||
<MessageSquarePlus size={18} /> Nuevo Chat
|
<MessageSquarePlus size={18} /> Nuevo Chat
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="flex flex-col gap-1 pr-3">
|
<div className="flex flex-col gap-1 pr-3">
|
||||||
|
{' '}
|
||||||
|
{/* Eliminado space-y-1 para mejor control con gap */}
|
||||||
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
@@ -551,51 +507,31 @@ export function IAAsignaturaTab() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</aside>
|
</div>
|
||||||
|
|
||||||
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
|
{/* PANEL CENTRAL */}
|
||||||
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:m-2">
|
<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">
|
||||||
{/* Header Interno del Recuadro */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
|
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||||
<Button
|
Asistente IA
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="md:hidden"
|
|
||||||
onClick={() => setIsSidebarOpen(true)}
|
|
||||||
>
|
|
||||||
<History size={20} className="text-slate-500" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
|
|
||||||
Asistente Académico
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-slate-400">
|
|
||||||
Personalizado para tu asignatura
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenIA(true)}
|
onClick={() => setOpenIA(true)}
|
||||||
className="flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
|
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"
|
||||||
>
|
>
|
||||||
<FileText size={14} />
|
<FileText size={14} className="text-slate-500" />
|
||||||
<span className="xs:inline hidden">Referencias</span>
|
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="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
|
||||||
{totalReferencias}
|
{totalReferencias}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Área de Mensajes */}
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<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-3 md:p-6">
|
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -706,9 +642,8 @@ export function IAAsignaturaTab() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input de Chat */}
|
{/* INPUT */}
|
||||||
<footer className="shrink-0 border-t bg-white p-3 md:p-4">
|
<div className="shrink-0 border-t bg-white p-4">
|
||||||
<div className="shrink-0 border-t bg-white p-2 md:p-4">
|
|
||||||
<div className="relative mx-auto max-w-4xl">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
||||||
@@ -770,28 +705,10 @@ export function IAAsignaturaTab() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value
|
setInput(e.target.value)
|
||||||
const cursor = e.target.selectionStart
|
if (e.target.value.endsWith(':')) setShowSuggestions(true)
|
||||||
setInput(val)
|
else if (showSuggestions && !e.target.value.includes(':'))
|
||||||
|
|
||||||
const textBeforeCursor = val.slice(0, cursor)
|
|
||||||
const lastColonIndex = textBeforeCursor.lastIndexOf(':')
|
|
||||||
|
|
||||||
if (lastColonIndex !== -1) {
|
|
||||||
const textSinceColon = textBeforeCursor.slice(
|
|
||||||
lastColonIndex + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Si hay un espacio después del ":", cerramos sugerencias (ya no es un comando)
|
|
||||||
// Si no hay espacio, activamos
|
|
||||||
if (!textSinceColon.includes(' ')) {
|
|
||||||
setShowSuggestions(true)
|
|
||||||
} else {
|
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setShowSuggestions(false)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -805,8 +722,7 @@ export function IAAsignaturaTab() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
disabled={
|
disabled={
|
||||||
(!input.trim() && selectedFields.length === 0) ||
|
(!input.trim() && selectedFields.length === 0) || isSending
|
||||||
isSending
|
|
||||||
}
|
}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
|
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
|
||||||
@@ -817,33 +733,29 @@ export function IAAsignaturaTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</div>
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* 3. PANEL DERECHO (ATAJOS) */}
|
{/* PANEL DERECHO ACCIONES */}
|
||||||
<aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto p-4 lg:flex">
|
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
||||||
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
|
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||||
<Lightbulb size={18} className="text-orange-500" /> Atajos Rápidos
|
<Lightbulb size={18} className="text-orange-500" /> Atajos
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{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 border-slate-100 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:shadow-sm"
|
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:bg-teal-50"
|
||||||
>
|
>
|
||||||
<div className="rounded-lg bg-slate-50 p-2 group-hover:bg-teal-50 group-hover:text-teal-600">
|
<div className="rounded-lg bg-slate-100 p-2 group-hover:bg-teal-100 group-hover:text-teal-600">
|
||||||
<preset.icon size={16} />
|
<preset.icon size={16} />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-slate-600 group-hover:text-slate-900">
|
<span className="font-medium text-slate-700">{preset.label}</span>
|
||||||
{preset.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
{/* --- DRAWER DE REFERENCIAS --- */}
|
||||||
{/* DRAWERS (Referencias e Historial Móvil) */}
|
|
||||||
<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="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">
|
||||||
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
|
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
|
||||||
@@ -878,140 +790,6 @@ export function IAAsignaturaTab() {
|
|||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
|
|
||||||
<DrawerPortal>
|
|
||||||
<DrawerOverlay className="fixed inset-0 bg-black/40" />
|
|
||||||
<DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none">
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
{/* Reutiliza aquí el componente de la lista de chats */}
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveChatId(undefined)
|
|
||||||
setIsCreatingNewChat(true)
|
|
||||||
setInput('')
|
|
||||||
setSelectedFields([])
|
|
||||||
queryClient.setQueryData(['subject-messages', undefined], [])
|
|
||||||
setIsSidebarOpen(false) // Cierra el drawer al crear nuevo
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="mb-6 w-full justify-center gap-2 border-dashed border-teal-500 bg-teal-50/50 text-teal-700 hover:bg-teal-100"
|
|
||||||
>
|
|
||||||
<MessageSquarePlus size={18} /> Nuevo Chat
|
|
||||||
</Button>
|
|
||||||
<h2 className="mb-4 font-bold">Historial de Chats</h2>
|
|
||||||
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
|
||||||
<div
|
|
||||||
key={chat.id}
|
|
||||||
className={cn(
|
|
||||||
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
|
|
||||||
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
|
|
||||||
activeChatId === chat.id
|
|
||||||
? 'bg-teal-50 text-teal-900'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100',
|
|
||||||
)}
|
|
||||||
onDoubleClick={() => {
|
|
||||||
setEditingId(chat.id)
|
|
||||||
setTempName(chat.nombre || chat.titulo || 'Conversacion')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{editingId === chat.id ? (
|
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
|
|
||||||
value={tempName}
|
|
||||||
onChange={(e) => setTempName(e.target.value)}
|
|
||||||
onBlur={() => handleSaveName(chat.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSaveName(chat.id)
|
|
||||||
if (e.key === 'Escape') setEditingId(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
|
|
||||||
<span
|
|
||||||
onClick={() => setActiveChatId(chat.id)}
|
|
||||||
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
|
|
||||||
title={chat.nombre || chat.titulo}
|
|
||||||
>
|
|
||||||
{chat.nombre || chat.titulo || 'Conversación'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
|
|
||||||
activeChatId === chat.id
|
|
||||||
? 'bg-teal-50'
|
|
||||||
: 'bg-slate-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setEditingId(chat.id)
|
|
||||||
setTempName(chat.nombre || chat.titulo || '')
|
|
||||||
}}
|
|
||||||
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
|
|
||||||
>
|
|
||||||
<Edit2 size={14} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="text-[10px]">
|
|
||||||
Editar nombre
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const nuevoEstado =
|
|
||||||
chat.estado === 'ACTIVA'
|
|
||||||
? 'ARCHIVADA'
|
|
||||||
: 'ACTIVA'
|
|
||||||
updateStatus({
|
|
||||||
id: chat.id,
|
|
||||||
estado: nuevoEstado,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'rounded-md p-1 transition-colors hover:bg-slate-200',
|
|
||||||
chat.estado === 'ACTIVA'
|
|
||||||
? 'hover:text-red-500'
|
|
||||||
: 'hover:text-teal-600',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{chat.estado === 'ACTIVA' ? (
|
|
||||||
<Archive size={14} />
|
|
||||||
) : (
|
|
||||||
<History size={14} className="scale-x-[-1]" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="text-[10px]">
|
|
||||||
{chat.estado === 'ACTIVA'
|
|
||||||
? 'Archivar'
|
|
||||||
: 'Desarchivar'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</DrawerPortal>
|
|
||||||
</Drawer>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
360
src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx
Normal file
360
src/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL = '__all__'
|
||||||
|
|
||||||
|
const normalizeLikeTerm = (term: string) =>
|
||||||
|
term.trim().replace(/[(),]/g, ' ').replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
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 to = from + pageSize - 1
|
||||||
|
|
||||||
|
let q = supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select(
|
||||||
|
'id,nombre,codigo,creditos,tipo,plan_estudio_id,estructura_id',
|
||||||
|
{
|
||||||
|
count: 'exact',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.order('nombre', { ascending: true })
|
||||||
|
|
||||||
|
if (planOrigenId) {
|
||||||
|
q = q.eq('plan_estudio_id', planOrigenId)
|
||||||
|
} else if (needPlansForFilter) {
|
||||||
|
const planIds = plansForFilter.map((p) => p.id)
|
||||||
|
if (!planIds.length) {
|
||||||
|
return { data: [] as Array<SourceSubjectRow>, count: 0 }
|
||||||
|
}
|
||||||
|
q = q.in('plan_estudio_id', planIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = normalizeLikeTerm(debouncedSearch)
|
||||||
|
if (term) {
|
||||||
|
// PostgREST OR syntax
|
||||||
|
q = q.or(`nombre.ilike.%${term}%,codigo.ilike.%${term}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
q = q.range(from, to)
|
||||||
|
|
||||||
|
const { data, error, count } = await q
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data as unknown as Array<SourceSubjectRow>,
|
||||||
|
count: count ?? 0,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
{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,85 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.')
|
||||||
|
|||||||
128
src/components/shadcn-studio/pagination/pagination-03.tsx
Normal file
128
src/components/shadcn-studio/pagination/pagination-03.tsx
Normal file
@@ -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
|
||||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,prerrequisito_asignatura_id,
|
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,
|
||||||
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))
|
||||||
|
|||||||
@@ -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,6 +65,11 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
basicos: 'Sugerencias',
|
basicos: 'Sugerencias',
|
||||||
detalles: 'Estructura',
|
detalles: 'Estructura',
|
||||||
}
|
}
|
||||||
|
: wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||||
|
? {
|
||||||
|
basicos: 'Fuente',
|
||||||
|
detalles: 'Datos básicos',
|
||||||
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -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>
|
||||||
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||||
|
<PasoFuenteClonadoInterno
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PasoBasicosForm 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>
|
||||||
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||||
|
<PasoBasicosClonadoInterno
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={setWizard}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PasoDetallesPanel 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
|
||||||
|
|||||||
@@ -218,31 +218,27 @@ function AsignaturasPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50">
|
<TableRow className="bg-slate-50/50">
|
||||||
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
|
<TableHead className="w-30">Clave</TableHead>
|
||||||
<TableHead className="px-6 py-4">Nombre</TableHead>
|
<TableHead>Nombre</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
|
<TableHead className="text-center">Créditos</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
|
<TableHead className="text-center">Ciclo</TableHead>
|
||||||
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
|
<TableHead>Línea Curricular</TableHead>
|
||||||
<TableHead className="px-6 py-4">Tipo</TableHead>
|
<TableHead>Tipo</TableHead>
|
||||||
<TableHead className="px-6 py-4">Estado</TableHead>
|
<TableHead>Estado</TableHead>
|
||||||
<TableHead className="w-12.5 px-6 py-4"></TableHead>
|
<TableHead className="w-12.5"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAsignaturas.length === 0 ? (
|
{filteredAsignaturas.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
|
<TableCell colSpan={8} className="h-40 text-center">
|
||||||
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
|
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
||||||
<BookOpen className="h-10 w-10 opacity-20" />
|
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
||||||
<div>
|
<p className="font-medium">No se encontraron asignaturas</p>
|
||||||
<p className="font-medium">
|
<p className="text-xs">
|
||||||
No se encontraron asignaturas
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
Intenta cambiar los filtros de búsqueda
|
Intenta cambiar los filtros de búsqueda
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -255,25 +251,25 @@ function AsignaturasPage() {
|
|||||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
params: {
|
params: {
|
||||||
planId,
|
planId,
|
||||||
asignaturaId: asignatura.id,
|
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
realId: asignatura.id,
|
realId: asignatura.id, // 👈 ID largo oculto
|
||||||
asignaturaId: asignatura.id,
|
asignaturaId: asignatura.id,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
|
<TableCell className="font-mono text-xs font-bold text-slate-400">
|
||||||
{asignatura.clave}
|
{asignatura.clave}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 font-semibold text-slate-700">
|
<TableCell className="font-semibold text-slate-700">
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 text-center font-medium">
|
<TableCell className="text-center font-medium">
|
||||||
{asignatura.creditos}
|
{asignatura.creditos}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 text-center">
|
<TableCell className="text-center">
|
||||||
{asignatura.ciclo ? (
|
{asignatura.ciclo ? (
|
||||||
<Badge variant="outline" className="font-normal">
|
<Badge variant="outline" className="font-normal">
|
||||||
Ciclo {asignatura.ciclo}
|
Ciclo {asignatura.ciclo}
|
||||||
@@ -282,10 +278,10 @@ function AsignaturasPage() {
|
|||||||
<span className="text-slate-300">—</span>
|
<span className="text-slate-300">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 text-sm text-slate-600">
|
<TableCell className="text-sm text-slate-600">
|
||||||
{getLineaNombre(asignatura.lineaCurricularId)}
|
{getLineaNombre(asignatura.lineaCurricularId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
||||||
@@ -293,7 +289,7 @@ function AsignaturasPage() {
|
|||||||
{tipoConfig[asignatura.tipo].label}
|
{tipoConfig[asignatura.tipo].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
||||||
@@ -301,7 +297,7 @@ function AsignaturasPage() {
|
|||||||
{statusConfig[asignatura.estado].label}
|
{statusConfig[asignatura.estado].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell>
|
||||||
<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>
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ function RouteComponent() {
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const [filterQuery, setFilterQuery] = useState('')
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
|
||||||
const [isActionsOpen, setIsActionsOpen] = useState(false)
|
|
||||||
|
|
||||||
const availableFields = useMemo(() => {
|
const availableFields = useMemo(() => {
|
||||||
const definicion = data?.estructuras_plan
|
const definicion = data?.estructuras_plan
|
||||||
?.definicion as EstructuraDefinicion
|
?.definicion as EstructuraDefinicion
|
||||||
@@ -210,7 +206,7 @@ function RouteComponent() {
|
|||||||
return messages
|
return messages
|
||||||
})
|
})
|
||||||
}, [mensajesDelChat, activeChatId, availableFields])
|
}, [mensajesDelChat, activeChatId, availableFields])
|
||||||
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
|
const scrollToBottom = (behavior = '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]',
|
||||||
@@ -218,7 +214,7 @@ function RouteComponent() {
|
|||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.scrollTo({
|
scrollContainer.scrollTo({
|
||||||
top: scrollContainer.scrollHeight,
|
top: scrollContainer.scrollHeight,
|
||||||
behavior,
|
behavior: behavior, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,38 +478,37 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full flex-col gap-6 overflow-hidden p-4 md:flex-row">
|
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
||||||
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
|
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-2 md:hidden">
|
<div className="flex w-64 flex-col border-r pr-4">
|
||||||
<Button
|
<div className="mb-4">
|
||||||
variant="ghost"
|
<div className="mb-4 flex items-center justify-between px-2">
|
||||||
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="mr-2 text-orange-500" /> Acciones
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
|
|
||||||
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
|
|
||||||
{/* ... (Tu código actual del historial de chats) ... */}
|
|
||||||
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
|
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
|
||||||
Chats
|
Chats
|
||||||
</h2>
|
</h2>
|
||||||
|
{/* Botón de toggle archivados movido aquí arriba */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
|
className={`rounded-md p-1.5 transition-colors ${
|
||||||
|
showArchived
|
||||||
|
? '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
|
<Button
|
||||||
onClick={createNewChat}
|
onClick={createNewChat}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-2 mb-4 w-full justify-start gap-2"
|
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
|
<MessageSquarePlus size={18} /> Nuevo chat
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 pr-2">
|
<div className="space-y-1 pr-2">
|
||||||
{' '}
|
{' '}
|
||||||
@@ -576,7 +571,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,
|
||||||
@@ -660,9 +655,8 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
{/* PANEL DE CHAT PRINCIPAL */}
|
||||||
{/* --- PANEL DE CHAT PRINCIPAL (Centro) --- */}
|
<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">
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:flex-[3]">
|
|
||||||
{/* NUEVO: Barra superior de campos seleccionados */}
|
{/* NUEVO: Barra superior de campos seleccionados */}
|
||||||
<div className="shrink-0 border-b bg-white p-3">
|
<div className="shrink-0 border-b bg-white p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -937,9 +931,8 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* PANEL LATERAL */}
|
||||||
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
|
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
||||||
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto pr-2 md:flex">
|
|
||||||
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
||||||
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
||||||
</h4>
|
</h4>
|
||||||
@@ -960,65 +953,6 @@ function RouteComponent() {
|
|||||||
))}
|
))}
|
||||||
</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 bg-teal-600 text-white"
|
|
||||||
>
|
|
||||||
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
|
|
||||||
</Button>
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
{/* Reutiliza aquí el mapeo de chats que tienes en el panel izquierdo */}
|
|
||||||
<p className="mb-4 text-xs font-bold text-slate-400 uppercase">
|
|
||||||
Historial Reciente
|
|
||||||
</p>
|
|
||||||
{activeChats.map((chat) => (
|
|
||||||
<div
|
|
||||||
key={chat.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveChatId(chat.id)
|
|
||||||
setIsHistoryOpen(false)
|
|
||||||
}}
|
|
||||||
className="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-orange-500" /> 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="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>
|
|
||||||
|
|
||||||
{/* Tu Drawer de Referencias IA se queda igual */}
|
|
||||||
<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="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">
|
||||||
{/* Cabecera más compacta */}
|
{/* Cabecera más compacta */}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { Plus, ChevronDown, AlertTriangle, Trash2, Pencil } from 'lucide-react'
|
import {
|
||||||
import * as Icons from 'lucide-react'
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
|
AlertTriangle,
|
||||||
|
Trash2,
|
||||||
|
Pencil,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useMemo, useState, useEffect, Fragment } from 'react'
|
import { useMemo, useState, useEffect, Fragment } from 'react'
|
||||||
|
|
||||||
import type { TipoAsignatura } from '@/data'
|
import type { TipoAsignatura } from '@/data'
|
||||||
@@ -31,12 +36,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import {
|
import {
|
||||||
useCreateLinea,
|
useCreateLinea,
|
||||||
useDeleteLinea,
|
useDeleteLinea,
|
||||||
@@ -46,6 +45,12 @@ import {
|
|||||||
useUpdateAsignatura,
|
useUpdateAsignatura,
|
||||||
useUpdateLinea,
|
useUpdateLinea,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
||||||
const palette = [
|
const palette = [
|
||||||
@@ -132,6 +137,8 @@ function StatItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
const estadoConfig: Record<
|
const estadoConfig: Record<
|
||||||
Asignatura['estado'],
|
Asignatura['estado'],
|
||||||
{
|
{
|
||||||
@@ -191,7 +198,7 @@ function AsignaturaCardItem({
|
|||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const estado = estadoConfig[asignatura.estado]
|
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
|
||||||
const EstadoIcon = estado.icon
|
const EstadoIcon = estado.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,10 +210,10 @@ function AsignaturaCardItem({
|
|||||||
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={[
|
className={[
|
||||||
'group relative h-50 w-40 shrink-0 overflow-hidden rounded-[22px] border text-left',
|
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
|
||||||
'transition-all duration-300 ease-out',
|
'transition-all duration-300 ease-out',
|
||||||
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||||
'cursor-grab active:cursor-grabbing',
|
'active:cursor-grabbing cursor-grab',
|
||||||
isDragging
|
isDragging
|
||||||
? 'scale-[0.985] opacity-45 shadow-none'
|
? 'scale-[0.985] opacity-45 shadow-none'
|
||||||
: 'hover:-translate-y-1 hover:shadow-lg',
|
: 'hover:-translate-y-1 hover:shadow-lg',
|
||||||
@@ -228,7 +235,7 @@ function AsignaturaCardItem({
|
|||||||
|
|
||||||
{/* glow decorativo */}
|
{/* glow decorativo */}
|
||||||
<div
|
<div
|
||||||
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl"
|
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl transition-transform duration-500 group-hover:scale-110"
|
||||||
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
|
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -236,7 +243,7 @@ function AsignaturaCardItem({
|
|||||||
{/* top */}
|
{/* top */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div
|
<div
|
||||||
className="inline-flex h-8 max-w-32 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
|
className="inline-flex h-8 max-w-[200px] items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
|
||||||
style={{
|
style={{
|
||||||
borderColor: hexToRgba(lineaColor, 0.2),
|
borderColor: hexToRgba(lineaColor, 0.2),
|
||||||
backgroundColor: hexToRgba(lineaColor, 0.1),
|
backgroundColor: hexToRgba(lineaColor, 0.1),
|
||||||
@@ -244,29 +251,37 @@ function AsignaturaCardItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
|
||||||
{asignatura.clave || 'Sin clave'}
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
|
||||||
|
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
|
||||||
|
<EstadoIcon
|
||||||
|
className={[
|
||||||
|
'h-3.5 w-3.5 text-foreground/65',
|
||||||
|
asignatura.estado === 'generando' ? 'animate-spin' : '',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'absolute right-2 flex translate-x-6 items-center gap-1.5 opacity-0 transition-all duration-300',
|
||||||
|
'group-hover:translate-x-0 group-hover:opacity-100'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-semibold whitespace-nowrap">
|
||||||
|
{estado.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="bg-background/70 flex h-8 items-center rounded-full px-2 backdrop-blur-sm">
|
|
||||||
<EstadoIcon className="text-foreground/65 h-3.5 w-3.5" />
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<span className="text-xs font-semibold">
|
|
||||||
{estado.label}
|
|
||||||
</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* titulo */}
|
{/* titulo */}
|
||||||
<div className="mt-4 min-h-18">
|
<div className="mt-4 min-h-[72px]">
|
||||||
<h3
|
<h3
|
||||||
className="text-foreground text-md overflow-hidden leading-[1.08] font-bold"
|
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
|
||||||
style={{
|
style={{
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
WebkitLineClamp: 3,
|
WebkitLineClamp: 3,
|
||||||
@@ -280,59 +295,53 @@ function AsignaturaCardItem({
|
|||||||
{/* bottom */}
|
{/* bottom */}
|
||||||
<div className="mt-auto grid grid-cols-3 gap-2">
|
<div className="mt-auto grid grid-cols-3 gap-2">
|
||||||
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
|
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<Icons.Award className="h-3.5 w-3.5" />
|
<Icons.Award className="h-3.5 w-3.5" />
|
||||||
<span className="text-[10px] font-medium tracking-wide uppercase">
|
<span className="text-[10px] font-medium uppercase tracking-wide">
|
||||||
CR
|
CR
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground text-sm font-bold">
|
<div className="text-sm font-bold text-foreground">
|
||||||
{asignatura.creditos}
|
{asignatura.creditos}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
|
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<Icons.Clock3 className="h-3.5 w-3.5" />
|
<Icons.Clock3 className="h-3.5 w-3.5" />
|
||||||
<span className="text-[10px] font-medium tracking-wide uppercase">
|
<span className="text-[10px] font-medium uppercase tracking-wide">
|
||||||
HD
|
HD
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground text-sm font-bold">
|
<div className="text-sm font-bold text-foreground">
|
||||||
{asignatura.hd}
|
{asignatura.hd}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="text-muted-foreground mb-1 flex items-center gap-1.5">
|
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<Icons.BookOpenText className="h-3.5 w-3.5" />
|
<Icons.BookOpenText className="h-3.5 w-3.5" />
|
||||||
<span className="text-[10px] font-medium tracking-wide uppercase">
|
<span className="text-[10px] font-medium uppercase tracking-wide">
|
||||||
HI
|
HI
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground text-sm font-bold">
|
<div className="text-sm font-bold text-foreground">
|
||||||
{asignatura.hi}
|
{asignatura.hi}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* drag affordance */}
|
{/* drag affordance */}
|
||||||
<div className="bg-background/70 pointer-events-none absolute right-3 bottom-3 rounded-full p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
|
<div className="pointer-events-none absolute right-3 bottom-3 rounded-full bg-background/70 p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
|
||||||
<Icons.GripVertical className="text-muted-foreground/55 h-4 w-4" />
|
<Icons.GripVertical className="h-4 w-4 text-muted-foreground/55" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<div className="text-lg">
|
<div className="text-xs">
|
||||||
{/* ciclo */}
|
{lineaNombre ? `${lineaNombre} · ` : ''}
|
||||||
{asignatura.ciclo ? (
|
|
||||||
<span className="font-bold">C{asignatura.ciclo} · </span>
|
|
||||||
) : null}
|
|
||||||
{lineaNombre ? (
|
|
||||||
<span className="font-medium">{lineaNombre} · </span>
|
|
||||||
) : null}
|
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -680,7 +689,7 @@ function MapaCurricularPage() {
|
|||||||
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
return <div className="p-10 text-center">Cargando mapa curricular...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container mx-auto px-2 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -759,10 +768,11 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto pb-6">
|
<div className="overflow-x-auto pb-6">
|
||||||
|
<div className="min-w-[1500px]">
|
||||||
<div
|
<div
|
||||||
className="grid gap-2"
|
className="grid gap-3"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `140px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
|
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="self-end px-2 text-xs font-bold text-slate-400">
|
<div className="self-end px-2 text-xs font-bold text-slate-400">
|
||||||
@@ -788,11 +798,10 @@ function MapaCurricularPage() {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={linea.id}>
|
<Fragment key={linea.id}>
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-3 transition-all ${
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
|
||||||
lineColors[idx % lineColors.length]
|
|
||||||
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<span
|
<span
|
||||||
contentEditable={editingLineaId === linea.id}
|
contentEditable={editingLineaId === linea.id}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
@@ -805,8 +814,7 @@ function MapaCurricularPage() {
|
|||||||
setTempNombreLinea(linea.nombre)
|
setTempNombreLinea(linea.nombre)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full truncate text-xs font-bold break-words outline-none ${
|
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
|
||||||
editingLineaId === linea.id
|
|
||||||
? 'cursor-text border-b border-teal-500/50 pb-1'
|
? 'cursor-text border-b border-teal-500/50 pb-1'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
@@ -814,7 +822,7 @@ function MapaCurricularPage() {
|
|||||||
{linea.nombre}
|
{linea.nombre}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex flex-shrink-0 items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLineaId(linea.id)}
|
onClick={() => setEditingLineaId(linea.id)}
|
||||||
className="..."
|
className="..."
|
||||||
@@ -835,12 +843,13 @@ function MapaCurricularPage() {
|
|||||||
key={`${linea.id}-${ciclo}`}
|
key={`${linea.id}-${ciclo}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
||||||
className="min-h-35 space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20"
|
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
|
||||||
>
|
>
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter(
|
.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
|
m.ciclo === ciclo &&
|
||||||
|
m.lineaCurricularId === linea.id,
|
||||||
)
|
)
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
@@ -859,27 +868,10 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div
|
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
|
||||||
className={`flex flex-col justify-center rounded-xl border p-4 text-[10px] font-medium ${
|
<div>Cr: {sub.cr}</div>
|
||||||
sub.cr === 0 && sub.hd === 0 && sub.hi === 0
|
<div>HD: {sub.hd}</div>
|
||||||
? 'border-slate-100 bg-slate-50/50 text-slate-300'
|
<div>HI: {sub.hi}</div>
|
||||||
: 'border-slate-200 bg-slate-50 text-slate-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sub.cr === 0 && sub.hd === 0 && sub.hi === 0 ? (
|
|
||||||
<div className="text-slate-400">—</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="font-bold text-slate-700">
|
|
||||||
Cr: {sub.cr}
|
|
||||||
</div>
|
|
||||||
<div className="text-slate-600">
|
|
||||||
HD: {sub.hd} • HI: {sub.hi}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
@@ -893,25 +885,15 @@ function MapaCurricularPage() {
|
|||||||
|
|
||||||
{ciclosArray.map((ciclo) => {
|
{ciclosArray.map((ciclo) => {
|
||||||
const t = getTotalesCiclo(ciclo)
|
const t = getTotalesCiclo(ciclo)
|
||||||
const isEmpty = t.cr === 0 && t.hd === 0 && t.hi === 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`footer-${ciclo}`}
|
key={`footer-${ciclo}`}
|
||||||
className={`rounded-lg p-2 text-center text-[10px] ${
|
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
|
||||||
isEmpty ? 'bg-slate-100/50 text-slate-400' : 'bg-slate-50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{isEmpty ? (
|
|
||||||
<div className="py-1 text-xs text-slate-400">—</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
|
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
|
||||||
<div>
|
<div>
|
||||||
HD: {t.hd} • HI: {t.hi}
|
HD: {t.hd} • HI: {t.hi}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -922,35 +904,36 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Asignaturas Sin Asignar */}
|
{/* Asignaturas Sin Asignar */}
|
||||||
<div className="border-border bg-card/80 mt-12 rounded-[28px] border p-5 shadow-sm backdrop-blur-sm">
|
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
|
||||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-muted text-muted-foreground flex h-9 w-9 items-center justify-center rounded-2xl">
|
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
<Icons.Inbox className="h-4.5 w-4.5" />
|
<Icons.Inbox className="h-4.5 w-4.5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-foreground text-sm font-bold tracking-wide uppercase">
|
<h3 className="text-sm font-bold tracking-wide text-foreground uppercase">
|
||||||
Bandeja de entrada
|
Bandeja de entrada
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="bg-muted text-muted-foreground inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-[11px] font-semibold">
|
<div className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-[11px] font-semibold text-muted-foreground">
|
||||||
{unassignedAsignaturas.length}
|
{unassignedAsignaturas.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
Asignaturas sin ciclo o línea curricular
|
Asignaturas sin ciclo o línea curricular
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border bg-background/80 text-muted-foreground flex items-center gap-2 rounded-full border border-dashed px-3 py-1.5 text-xs">
|
<div className="flex items-center gap-2 rounded-full border border-dashed border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
<Icons.MoveDown className="h-3.5 w-3.5" />
|
<Icons.MoveDown className="h-3.5 w-3.5" />
|
||||||
<span>Arrastra aquí para desasignar</span>
|
<span>Arrastra aquí para desasignar</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -986,18 +969,18 @@ function MapaCurricularPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border-border/70 bg-background/70 flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border px-6 text-center">
|
<div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
|
||||||
<div className="bg-muted text-muted-foreground mb-3 flex h-12 w-12 items-center justify-center rounded-2xl">
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
<Icons.CheckCheck className="h-5 w-5" />
|
<Icons.CheckCheck className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground text-sm font-semibold">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
No hay asignaturas pendientes
|
No hay asignaturas pendientes
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 max-w-md text-sm">
|
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||||
Todo está colocado en el mapa. Arrastra una asignatura aquí para
|
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
|
||||||
quitarle ciclo y línea curricular.
|
ciclo y línea curricular.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user