Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd816bfa1 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,13 @@ import type { AsignaturaDetail } from '@/data'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -14,8 +21,8 @@ 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'
|
|
||||||
|
|
||||||
export interface BibliografiaEntry {
|
export interface BibliografiaEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -39,10 +46,6 @@ export interface AsignaturaResponse {
|
|||||||
datos: AsignaturaDatos
|
datos: AsignaturaDatos
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CriterioEvaluacionRow = {
|
type CriterioEvaluacionRow = {
|
||||||
criterio: string
|
criterio: string
|
||||||
porcentaje: number
|
porcentaje: number
|
||||||
@@ -64,8 +67,12 @@ export default function AsignaturaDetailPage() {
|
|||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
|
const { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||||
|
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||||
|
usePlanAsignaturas(planId)
|
||||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
@@ -86,16 +93,54 @@ export default function AsignaturaDetailPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asignaturaSeriada = useMemo(() => {
|
||||||
|
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
|
||||||
|
return null
|
||||||
|
return asignaturasApi.find(
|
||||||
|
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
|
||||||
|
)
|
||||||
|
}, [asignaturaApi, asignaturasApi])
|
||||||
|
const requisitosFormateados = useMemo(() => {
|
||||||
|
if (!asignaturaSeriada) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: asignaturaSeriada.codigo,
|
||||||
|
name: asignaturaSeriada.nombre,
|
||||||
|
id: asignaturaSeriada.id, // Guardamos el ID para el select
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [asignaturaSeriada])
|
||||||
|
|
||||||
|
const handleUpdatePrerrequisito = (newId: string | null) => {
|
||||||
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch: {
|
||||||
|
prerrequisito_asignatura_id: newId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
/* ---------- sincronizar API ---------- */
|
/* ---------- sincronizar API ---------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
console.log(requisitosFormateados)
|
||||||
}, [asignaturaApi])
|
|
||||||
|
|
||||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
|
}, [asignaturaApi, requisitosFormateados])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatosGenerales
|
||||||
|
pre={requisitosFormateados}
|
||||||
|
availableSubjects={asignaturasApi}
|
||||||
|
onPersistDato={handlePersistDatoGeneral}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatosGenerales({
|
function DatosGenerales({
|
||||||
onPersistDato,
|
onPersistDato,
|
||||||
|
pre,
|
||||||
|
availableSubjects,
|
||||||
}: {
|
}: {
|
||||||
onPersistDato: (clave: string, value: string) => void
|
onPersistDato: (clave: string, value: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -270,18 +315,19 @@ function DatosGenerales({
|
|||||||
<InfoCard
|
<InfoCard
|
||||||
title="Requisitos y Seriación"
|
title="Requisitos y Seriación"
|
||||||
type="requirements"
|
type="requirements"
|
||||||
initialContent={[
|
initialContent={pre}
|
||||||
{
|
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
||||||
type: 'Pre-requisito',
|
availableSubjects={
|
||||||
code: 'PA-301',
|
availableSubjects?.filter((a) => a.id !== asignaturaId) || []
|
||||||
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 */}
|
||||||
@@ -321,6 +367,7 @@ interface InfoCardProps {
|
|||||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
containerRef?: React.RefObject<HTMLDivElement | null>
|
||||||
forceEditToken?: number
|
forceEditToken?: number
|
||||||
highlightToken?: number
|
highlightToken?: number
|
||||||
|
availableSubjects?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
@@ -337,6 +384,7 @@ function InfoCard({
|
|||||||
containerRef,
|
containerRef,
|
||||||
forceEditToken,
|
forceEditToken,
|
||||||
highlightToken,
|
highlightToken,
|
||||||
|
availableSubjects,
|
||||||
}: InfoCardProps) {
|
}: InfoCardProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
@@ -354,7 +402,8 @@ 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
|
||||||
@@ -397,6 +446,8 @@ 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> = []
|
||||||
@@ -427,6 +478,25 @@ 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)
|
||||||
@@ -546,7 +616,52 @@ function InfoCard({
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{type === 'evaluation' ? (
|
{/* Condicionales de edición según el tipo */}
|
||||||
|
{type === 'requirements' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-medium text-slate-500">
|
||||||
|
Materia de Seriación
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={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) => (
|
||||||
@@ -568,85 +683,36 @@ function InfoCard({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={row.porcentaje}
|
value={row.porcentaje}
|
||||||
placeholder="%"
|
placeholder="%"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
// Solo permitir '' o dígitos
|
|
||||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||||
|
|
||||||
if (raw === '') {
|
|
||||||
setEvalRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.id === row.id
|
|
||||||
? {
|
|
||||||
id: r.id,
|
|
||||||
criterio: r.criterio,
|
|
||||||
porcentaje: '',
|
|
||||||
}
|
|
||||||
: r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const n = Number(raw)
|
|
||||||
if (!Number.isFinite(n)) return
|
|
||||||
const porcentaje = Math.trunc(n)
|
|
||||||
if (porcentaje < 1 || porcentaje > 100) return
|
|
||||||
|
|
||||||
// No permitir suma > 100
|
|
||||||
setEvalRows((prev) => {
|
setEvalRows((prev) => {
|
||||||
const next = prev.map((r) =>
|
const next = prev.map((r) =>
|
||||||
r.id === row.id
|
r.id === row.id ? { ...r, porcentaje: raw } : r,
|
||||||
? {
|
)
|
||||||
id: r.id,
|
const total = next.reduce(
|
||||||
criterio: r.criterio,
|
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
||||||
porcentaje: raw,
|
0,
|
||||||
}
|
|
||||||
: r,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const total = next.reduce((acc, r) => {
|
|
||||||
const v = String(r.porcentaje).trim()
|
|
||||||
if (!v) return acc
|
|
||||||
const nn = Number(v)
|
|
||||||
if (!Number.isFinite(nn)) return acc
|
|
||||||
const vv = Math.trunc(nn)
|
|
||||||
if (vv < 1 || vv > 100) return acc
|
|
||||||
return acc + vv
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
return total > 100 ? prev : next
|
return total > 100 ? prev : next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="text-sm text-slate-600">%</div>
|
||||||
<div
|
|
||||||
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
setEvalRows((prev) =>
|
setEvalRows((prev) =>
|
||||||
prev.filter((r) => r.id !== row.id),
|
prev.filter((r) => r.id !== row.id),
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
aria-label="Quitar renglón"
|
|
||||||
title="Quitar"
|
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -656,22 +722,15 @@ function InfoCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={
|
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
||||||
'text-sm ' +
|
|
||||||
(evaluationTotal === 100
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: 'text-destructive font-semibold')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Total: {evaluationTotal}/100
|
Total: {evaluationTotal}/100
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-emerald-700 hover:bg-emerald-50"
|
className="text-emerald-700 hover:bg-emerald-50"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
// Agregar una fila vacía (siempre permitido)
|
|
||||||
setEvalRows((prev) => [
|
setEvalRows((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -680,7 +739,7 @@ function InfoCard({
|
|||||||
porcentaje: '',
|
porcentaje: '',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||||
</Button>
|
</Button>
|
||||||
@@ -694,28 +753,15 @@ function InfoCard({
|
|||||||
className="min-h-30 text-sm leading-relaxed"
|
className="min-h-30 text-sm leading-relaxed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Botones de acción comunes */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
if (type === 'evaluation') {
|
// Lógica de reset si es necesario...
|
||||||
const raw = Array.isArray(data) ? data : []
|
|
||||||
setEvalRows(
|
|
||||||
raw.map((r: CriterioEvaluacionRow) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
criterio:
|
|
||||||
typeof r.criterio === 'string' ? r.criterio : '',
|
|
||||||
porcentaje:
|
|
||||||
typeof r.porcentaje === 'number'
|
|
||||||
? String(Math.trunc(r.porcentaje))
|
|
||||||
: typeof r.porcentaje === 'string'
|
|
||||||
? String(Math.trunc(Number(r.porcentaje)))
|
|
||||||
: '',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
@@ -731,6 +777,7 @@ function InfoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
/* Modo Visualización */
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
{type === 'text' &&
|
{type === 'text' &&
|
||||||
(data ? (
|
(data ? (
|
||||||
@@ -739,9 +786,7 @@ function InfoCard({
|
|||||||
<p className="text-slate-400 italic">Sin información.</p>
|
<p className="text-slate-400 italic">Sin información.</p>
|
||||||
))}
|
))}
|
||||||
{type === 'requirements' && <RequirementsView items={data} />}
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
{type === 'evaluation' && (
|
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||||
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -796,3 +841,80 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContenidoTematicoToPlainText(value: unknown): string {
|
||||||
|
if (!Array.isArray(value)) return ''
|
||||||
|
|
||||||
|
const blocks: Array<string> = []
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
|
||||||
|
const unidad =
|
||||||
|
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
||||||
|
? item.unidad
|
||||||
|
: undefined
|
||||||
|
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
||||||
|
|
||||||
|
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
||||||
|
if (!header) continue
|
||||||
|
|
||||||
|
const lines: Array<string> = [header]
|
||||||
|
|
||||||
|
const temas = Array.isArray(item.temas) ? item.temas : []
|
||||||
|
temas.forEach((tema, idx) => {
|
||||||
|
const temaNombre =
|
||||||
|
typeof tema === 'string'
|
||||||
|
? tema
|
||||||
|
: isRecord(tema) && typeof tema.nombre === 'string'
|
||||||
|
? tema.nombre
|
||||||
|
: ''
|
||||||
|
if (!temaNombre) return
|
||||||
|
|
||||||
|
if (unidad != null) {
|
||||||
|
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
||||||
|
} else {
|
||||||
|
lines.push(`${idx + 1}. ${temaNombre}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
blocks.push(lines.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.join('\n\n').trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
||||||
|
if (!Array.isArray(value)) return ''
|
||||||
|
|
||||||
|
const lines: Array<string> = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
||||||
|
const valueNum =
|
||||||
|
typeof item.porcentaje === 'number'
|
||||||
|
? item.porcentaje
|
||||||
|
: typeof item.porcentaje === 'string'
|
||||||
|
? Number(item.porcentaje)
|
||||||
|
: NaN
|
||||||
|
|
||||||
|
if (!label) continue
|
||||||
|
if (!Number.isFinite(valueNum)) continue
|
||||||
|
|
||||||
|
const v = Math.trunc(valueNum)
|
||||||
|
if (v < 1 || v > 100) continue
|
||||||
|
|
||||||
|
lines.push(`${label}: ${v}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||||
|
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||||
|
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import { Card } from '@/components/ui/card'
|
|||||||
interface DocumentoSEPTabProps {
|
interface DocumentoSEPTabProps {
|
||||||
pdfUrl: string | null
|
pdfUrl: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
onDownloadPdf: () => void
|
onDownload: () => void
|
||||||
onDownloadWord: () => void
|
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
@@ -27,8 +26,7 @@ interface DocumentoSEPTabProps {
|
|||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
onDownloadPdf,
|
onDownload,
|
||||||
onDownloadWord,
|
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
isRegenerating,
|
isRegenerating,
|
||||||
}: DocumentoSEPTabProps) {
|
}: DocumentoSEPTabProps) {
|
||||||
@@ -54,23 +52,25 @@ export function DocumentoSEPTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{pdfUrl && !isLoading && (
|
||||||
|
<Button variant="outline" onClick={onDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Descargar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={showConfirmDialog}
|
open={showConfirmDialog}
|
||||||
onOpenChange={setShowConfirmDialog}
|
onOpenChange={setShowConfirmDialog}
|
||||||
>
|
>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button disabled={isRegenerating}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isRegenerating}
|
|
||||||
>
|
|
||||||
{isRegenerating ? (
|
{isRegenerating ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
@@ -91,31 +91,11 @@ export function DocumentoSEPTab({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{pdfUrl && !isLoading && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
|
||||||
onClick={onDownloadWord}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Descargar Word
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={onDownloadPdf}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Descargar PDF
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF Preview */}
|
{/* PDF Preview */}
|
||||||
<Card className="h-200 overflow-hidden">
|
<Card className="h-[800px] overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-10 w-10 animate-spin" />
|
<Loader2 className="h-10 w-10 animate-spin" />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,52 @@
|
|||||||
// document.api.ts
|
// document.api.ts
|
||||||
|
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
const DOCUMENT_PDF_URL =
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
||||||
|
|
||||||
import { requireData, throwIfError } from './_helpers'
|
const DOCUMENT_PDF_ASIGNATURA_URL =
|
||||||
|
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
|
||||||
import type { Tables } from '@/types/supabase'
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
carbone_io_wrapper: 'carbone-io-wrapper',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
interface GeneratePdfParams {
|
interface GeneratePdfParams {
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
convertTo?: 'pdf'
|
|
||||||
}
|
}
|
||||||
interface GeneratePdfParamsAsignatura {
|
interface GeneratePdfParamsAsignatura {
|
||||||
asignatura_id: string
|
asignatura_id: string
|
||||||
convertTo?: 'pdf'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPlanPdf({
|
export async function fetchPlanPdf({
|
||||||
plan_estudio_id,
|
plan_estudio_id,
|
||||||
convertTo,
|
|
||||||
}: GeneratePdfParams): Promise<Blob> {
|
}: GeneratePdfParams): Promise<Blob> {
|
||||||
return await invokeEdge<Blob>(
|
const response = await fetch(DOCUMENT_PDF_URL, {
|
||||||
EDGE.carbone_io_wrapper,
|
method: 'POST',
|
||||||
{
|
|
||||||
action: 'downloadReport',
|
|
||||||
plan_estudio_id,
|
|
||||||
body: convertTo ? { convertTo } : {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
responseType: 'blob',
|
body: JSON.stringify({ plan_estudio_id }),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error al generar el PDF')
|
||||||
|
}
|
||||||
|
|
||||||
|
// n8n devuelve el archivo → lo tratamos como blob
|
||||||
|
return await response.blob()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAsignaturaPdf({
|
export async function fetchAsignaturaPdf({
|
||||||
asignatura_id,
|
asignatura_id,
|
||||||
convertTo,
|
|
||||||
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
||||||
const supabase = supabaseBrowser()
|
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
|
||||||
|
method: 'POST',
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', asignatura_id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
const row = requireData(
|
|
||||||
data as Pick<
|
|
||||||
Tables<'asignaturas'>,
|
|
||||||
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
|
|
||||||
>,
|
|
||||||
'Asignatura no encontrada',
|
|
||||||
)
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
data: row,
|
|
||||||
}
|
|
||||||
if (convertTo) body.convertTo = convertTo
|
|
||||||
|
|
||||||
return await invokeEdge<Blob>(
|
|
||||||
EDGE.carbone_io_wrapper,
|
|
||||||
{
|
|
||||||
action: 'downloadReport',
|
|
||||||
asignatura_id,
|
|
||||||
body: {
|
|
||||||
...body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
responseType: 'blob',
|
body: JSON.stringify({ asignatura_id }),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error al generar el PDF')
|
||||||
|
}
|
||||||
|
|
||||||
|
// n8n devuelve el archivo → lo tratamos como blob
|
||||||
|
return await response.blob()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
|||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
|
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
|
||||||
planes_estudio(
|
planes_estudio(
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
|||||||
export type EdgeInvokeOptions = {
|
export type EdgeInvokeOptions = {
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EdgeFunctionError extends Error {
|
export class EdgeFunctionError extends Error {
|
||||||
@@ -27,55 +26,6 @@ export class EdgeFunctionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soporta base64 puro o data:...;base64,...
|
|
||||||
function decodeBase64ToUint8Array(input: string): Uint8Array {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
const base64 = trimmed.startsWith('data:')
|
|
||||||
? trimmed.slice(trimmed.indexOf(',') + 1)
|
|
||||||
: trimmed
|
|
||||||
|
|
||||||
const bin = atob(base64)
|
|
||||||
const bytes = new Uint8Array(bin.length)
|
|
||||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripDataUrlPrefix(input: string): string {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
if (!trimmed.startsWith('data:')) return trimmed
|
|
||||||
const commaIdx = trimmed.indexOf(',')
|
|
||||||
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeBase64(s: string): boolean {
|
|
||||||
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
|
|
||||||
|
|
||||||
// base64 típico: solo chars permitidos y longitud razonable
|
|
||||||
if (t.length < 64) return false
|
|
||||||
return /^[A-Za-z0-9+/]+$/.test(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startsWithZip(bytes: Uint8Array): boolean {
|
|
||||||
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
|
|
||||||
}
|
|
||||||
|
|
||||||
function startsWithPdf(bytes: Uint8Array): boolean {
|
|
||||||
return (
|
|
||||||
bytes.length >= 5 &&
|
|
||||||
bytes[0] === 0x25 &&
|
|
||||||
bytes[1] === 0x50 &&
|
|
||||||
bytes[2] === 0x44 &&
|
|
||||||
bytes[3] === 0x46 &&
|
|
||||||
bytes[4] === 0x2d
|
|
||||||
) // "%PDF-"
|
|
||||||
}
|
|
||||||
|
|
||||||
function binaryStringToUint8Array(input: string): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(input.length)
|
|
||||||
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invokeEdge<TOut>(
|
export async function invokeEdge<TOut>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
body?:
|
body?:
|
||||||
@@ -92,16 +42,10 @@ export async function invokeEdge<TOut>(
|
|||||||
): Promise<TOut> {
|
): Promise<TOut> {
|
||||||
const supabase = client ?? supabaseBrowser()
|
const supabase = client ?? supabaseBrowser()
|
||||||
|
|
||||||
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
|
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||||
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
|
|
||||||
const invoke: any = (supabase.functions as any).invoke.bind(
|
|
||||||
supabase.functions,
|
|
||||||
)
|
|
||||||
const { data, error } = await invoke(functionName, {
|
|
||||||
body,
|
body,
|
||||||
method: opts.method ?? 'POST',
|
method: opts.method ?? 'POST',
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
responseType: opts.responseType,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -160,20 +104,5 @@ export async function invokeEdge<TOut>(
|
|||||||
throw new EdgeFunctionError(message, functionName, status, details)
|
throw new EdgeFunctionError(message, functionName, status, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.responseType === 'blob') {
|
|
||||||
const anyData: unknown = data
|
|
||||||
|
|
||||||
if (anyData instanceof Blob) {
|
|
||||||
return anyData as TOut
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new EdgeFunctionError(
|
|
||||||
'La Edge Function no devolvió un binario (Blob) válido.',
|
|
||||||
functionName,
|
|
||||||
undefined,
|
|
||||||
{ receivedType: typeof anyData, received: anyData },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as TOut
|
return data as TOut
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseContenidoTematicoToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const blocks: Array<string> = []
|
|
||||||
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
|
|
||||||
const unidad =
|
|
||||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
|
||||||
? item.unidad
|
|
||||||
: undefined
|
|
||||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
|
||||||
|
|
||||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
|
||||||
if (!header) continue
|
|
||||||
|
|
||||||
const lines: Array<string> = [header]
|
|
||||||
|
|
||||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
|
||||||
temas.forEach((tema, idx) => {
|
|
||||||
const temaNombre =
|
|
||||||
typeof tema === 'string'
|
|
||||||
? tema
|
|
||||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
|
||||||
? tema.nombre
|
|
||||||
: ''
|
|
||||||
if (!temaNombre) return
|
|
||||||
|
|
||||||
if (unidad != null) {
|
|
||||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
|
||||||
} else {
|
|
||||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks.push(lines.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.join('\n\n').trimEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const lines: Array<string> = []
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
|
||||||
const valueNum =
|
|
||||||
typeof item.porcentaje === 'number'
|
|
||||||
? item.porcentaje
|
|
||||||
: typeof item.porcentaje === 'string'
|
|
||||||
? Number(item.porcentaje)
|
|
||||||
: NaN
|
|
||||||
|
|
||||||
if (!label) continue
|
|
||||||
if (!Number.isFinite(valueNum)) continue
|
|
||||||
|
|
||||||
const v = Math.trunc(valueNum)
|
|
||||||
if (v < 1 || v > 100) continue
|
|
||||||
|
|
||||||
lines.push(`${label}: ${v}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columnParsers: Partial<
|
|
||||||
Record<string, (value: unknown) => string>
|
|
||||||
> = {
|
|
||||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
|
||||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileJson,
|
FileJson,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { usePlan } from '@/data'
|
|
||||||
import { fetchPlanPdf } from '@/data/api/document.api'
|
import { fetchPlanPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
||||||
@@ -21,41 +20,30 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
||||||
const { data: plan } = usePlan(planId)
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const pdfUrlRef = useRef<string | null>(null)
|
|
||||||
const isMountedRef = useRef<boolean>(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
|
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isMountedRef.current) setIsLoading(true)
|
setIsLoading(true)
|
||||||
const pdfBlob = await fetchPlanPdf({
|
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
|
||||||
plan_estudio_id: planId,
|
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
// Limpiar URL anterior si existe para evitar fugas de memoria
|
||||||
pdfUrlRef.current = url
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
|
|
||||||
setPdfUrl(url)
|
setPdfUrl(url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando preview:', error)
|
console.error('Error cargando preview:', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [planId])
|
}, [planId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true
|
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
@@ -63,13 +51,12 @@ function RouteComponent() {
|
|||||||
try {
|
try {
|
||||||
const pdfBlob = await fetchPlanPdf({
|
const pdfBlob = await fetchPlanPdf({
|
||||||
plan_estudio_id: planId,
|
plan_estudio_id: planId,
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `${planFileBaseName}.pdf`
|
link.download = 'plan_estudios.pdf'
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
|
||||||
@@ -80,27 +67,6 @@ function RouteComponent() {
|
|||||||
alert('No se pudo generar el PDF')
|
alert('No se pudo generar el PDF')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadWord = async () => {
|
|
||||||
try {
|
|
||||||
const docBlob = await fetchPlanPdf({
|
|
||||||
plan_estudio_id: planId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(docBlob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `${planFileBaseName}.docx`
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
|
|
||||||
link.remove()
|
|
||||||
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
alert('No se pudo generar el Word')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
@@ -122,17 +88,12 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<RefreshCcw size={16} /> Regenerar
|
<RefreshCcw size={16} /> Regenerar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
size="sm"
|
|
||||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
|
||||||
onClick={handleDownloadWord}
|
|
||||||
>
|
|
||||||
<Download size={16} /> Descargar Word
|
<Download size={16} /> Descargar Word
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
onClick={handleDownloadPdf}
|
onClick={handleDownloadPdf}
|
||||||
>
|
>
|
||||||
<Download size={16} /> Descargar PDF
|
<Download size={16} /> Descargar PDF
|
||||||
@@ -178,7 +139,7 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
|
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||||
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
||||||
@@ -188,7 +149,7 @@ function RouteComponent() {
|
|||||||
/* 3. VISOR DE PDF REAL */
|
/* 3. VISOR DE PDF REAL */
|
||||||
<iframe
|
<iframe
|
||||||
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
||||||
className="h-250 w-full max-w-250 border-none shadow-2xl"
|
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -202,24 +163,6 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFileBaseName(input: string): string {
|
|
||||||
const text = String(input)
|
|
||||||
const withoutControlChars = Array.from(text)
|
|
||||||
.filter((ch) => {
|
|
||||||
const code = ch.charCodeAt(0)
|
|
||||||
return code >= 32 && code !== 127
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
const cleaned = withoutControlChars
|
|
||||||
.replace(/[<>:"/\\|?*]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/g, '')
|
|
||||||
|
|
||||||
return (cleaned || 'documento').slice(0, 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Componente pequeño para las tarjetas de estado superior
|
// Componente pequeño para las tarjetas de estado superior
|
||||||
function StatusCard({
|
function StatusCard({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
GripVertical,
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -45,33 +46,16 @@ 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 = [
|
|
||||||
'#4F46E5', // índigo
|
|
||||||
'#7C3AED', // violeta
|
|
||||||
'#EA580C', // naranja
|
|
||||||
'#059669', // esmeralda
|
|
||||||
'#DC2626', // rojo
|
|
||||||
'#0891B2', // cyan
|
|
||||||
'#CA8A04', // ámbar
|
|
||||||
'#C026D3', // fucsia
|
|
||||||
]
|
|
||||||
|
|
||||||
const mapLineasToLineaCurricular = (
|
const mapLineasToLineaCurricular = (
|
||||||
lineasApi: Array<any> = [],
|
lineasApi: Array<any> = [],
|
||||||
): Array<LineaCurricular> => {
|
): Array<LineaCurricular> => {
|
||||||
return lineasApi.map((linea, index) => ({
|
return lineasApi.map((linea) => ({
|
||||||
id: linea.id,
|
id: linea.id,
|
||||||
nombre: linea.nombre,
|
nombre: linea.nombre,
|
||||||
orden: linea.orden ?? 0,
|
orden: linea.orden ?? 0,
|
||||||
color: palette[index % palette.length],
|
color: '#1976d2',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,216 +121,52 @@ function StatItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
import * as Icons from 'lucide-react'
|
|
||||||
|
|
||||||
const estadoConfig: Record<
|
|
||||||
Asignatura['estado'],
|
|
||||||
{
|
|
||||||
label: string
|
|
||||||
dot: string
|
|
||||||
soft: string
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
borrador: {
|
|
||||||
label: 'Borrador',
|
|
||||||
dot: 'bg-slate-500',
|
|
||||||
soft: 'bg-slate-100 text-slate-700',
|
|
||||||
icon: Icons.FileText,
|
|
||||||
},
|
|
||||||
revisada: {
|
|
||||||
label: 'Revisada',
|
|
||||||
dot: 'bg-amber-500',
|
|
||||||
soft: 'bg-amber-100 text-amber-700',
|
|
||||||
icon: Icons.ScanSearch,
|
|
||||||
},
|
|
||||||
aprobada: {
|
|
||||||
label: 'Aprobada',
|
|
||||||
dot: 'bg-emerald-500',
|
|
||||||
soft: 'bg-emerald-100 text-emerald-700',
|
|
||||||
icon: Icons.BadgeCheck,
|
|
||||||
},
|
|
||||||
generando: {
|
|
||||||
label: 'Generando',
|
|
||||||
dot: 'bg-sky-500',
|
|
||||||
soft: 'bg-sky-100 text-sky-700',
|
|
||||||
icon: Icons.LoaderCircle,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number) {
|
|
||||||
const clean = hex.replace('#', '')
|
|
||||||
const bigint = parseInt(clean, 16)
|
|
||||||
const r = (bigint >> 16) & 255
|
|
||||||
const g = (bigint >> 8) & 255
|
|
||||||
const b = bigint & 255
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
||||||
}
|
|
||||||
|
|
||||||
function AsignaturaCardItem({
|
function AsignaturaCardItem({
|
||||||
asignatura,
|
asignatura,
|
||||||
lineaColor,
|
|
||||||
lineaNombre,
|
|
||||||
onDragStart,
|
onDragStart,
|
||||||
isDragging,
|
isDragging,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
asignatura: Asignatura
|
asignatura: Asignatura
|
||||||
lineaColor: string
|
|
||||||
lineaNombre?: string
|
|
||||||
onDragStart: (e: React.DragEvent, id: string) => void
|
onDragStart: (e: React.DragEvent, id: string) => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
|
|
||||||
const EstadoIcon = estado.icon
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={150}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
<button
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={[
|
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
|
||||||
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
|
|
||||||
'transition-all duration-300 ease-out',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
|
||||||
'active:cursor-grabbing cursor-grab',
|
|
||||||
isDragging
|
isDragging
|
||||||
? 'scale-[0.985] opacity-45 shadow-none'
|
? 'scale-95 opacity-40'
|
||||||
: 'hover:-translate-y-1 hover:shadow-lg',
|
: 'hover:border-teal-400 hover:shadow-md'
|
||||||
].join(' ')}
|
}`}
|
||||||
style={{
|
|
||||||
borderColor: hexToRgba(lineaColor, 0.18),
|
|
||||||
background: `
|
|
||||||
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
|
|
||||||
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
title={asignatura.nombre}
|
|
||||||
>
|
>
|
||||||
{/* franja */}
|
<div className="mb-1 flex items-start justify-between">
|
||||||
<div
|
<span className="font-mono text-[10px] font-bold text-slate-400">
|
||||||
className="absolute inset-x-0 top-0 h-2"
|
{asignatura.clave}
|
||||||
style={{ backgroundColor: lineaColor }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* glow decorativo */}
|
|
||||||
<div
|
|
||||||
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) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex h-full flex-col p-4">
|
|
||||||
{/* top */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div
|
|
||||||
className="inline-flex h-8 max-w-[200px] items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
|
|
||||||
style={{
|
|
||||||
borderColor: hexToRgba(lineaColor, 0.2),
|
|
||||||
backgroundColor: hexToRgba(lineaColor, 0.1),
|
|
||||||
color: lineaColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
|
|
||||||
</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>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
</div>
|
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* titulo */}
|
|
||||||
<div className="mt-4 min-h-[72px]">
|
|
||||||
<h3
|
|
||||||
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
|
|
||||||
style={{
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 3,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{asignatura.estado}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</h3>
|
</p>
|
||||||
</div>
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
{/* bottom */}
|
{asignatura.creditos} CR • HD:{asignatura.hd} • HI:{asignatura.hi}
|
||||||
<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="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icons.Award className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">
|
|
||||||
CR
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<GripVertical
|
||||||
<div className="text-sm font-bold text-foreground">
|
size={12}
|
||||||
{asignatura.creditos}
|
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
</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="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icons.Clock3 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">
|
|
||||||
HD
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-foreground">
|
|
||||||
{asignatura.hd}
|
|
||||||
</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="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icons.BookOpenText className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">
|
|
||||||
HI
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-foreground">
|
|
||||||
{asignatura.hi}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* drag affordance */}
|
|
||||||
<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="h-4 w-4 text-muted-foreground/55" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<div className="text-xs">
|
|
||||||
{lineaNombre ? `${lineaNombre} · ` : ''}
|
|
||||||
{asignatura.nombre}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +618,8 @@ 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-4 transition-all ${lineColors[idx % lineColors.length]
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
||||||
|
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="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -814,7 +635,8 @@ function MapaCurricularPage() {
|
|||||||
setTempNombreLinea(linea.nombre)
|
setTempNombreLinea(linea.nombre)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
|
className={`block w-full text-xs font-bold break-words outline-none ${
|
||||||
|
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'
|
||||||
}`}
|
}`}
|
||||||
@@ -855,8 +677,6 @@ function MapaCurricularPage() {
|
|||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
lineaColor={linea.color || '#1976d2'}
|
|
||||||
lineaNombre={linea.nombre}
|
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -907,81 +727,45 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Asignaturas Sin Asignar */}
|
{/* Asignaturas Sin Asignar */}
|
||||||
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
|
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-sm font-bold tracking-wider uppercase">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
Bandeja de Entrada / Asignaturas sin asignar
|
||||||
<Icons.Inbox className="h-4.5 w-4.5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-sm font-bold tracking-wide text-foreground uppercase">
|
|
||||||
Bandeja de entrada
|
|
||||||
</h3>
|
</h3>
|
||||||
|
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-xs text-slate-400">
|
||||||
|
Arrastra una asignatura aquí para quitarla del mapa
|
||||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
||||||
Asignaturas sin ciclo o línea curricular
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
<span>Arrastra aquí para desasignar</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
|
||||||
onDrop={(e) => handleDrop(e, null, null)}
|
|
||||||
className={[
|
|
||||||
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
|
|
||||||
'min-h-[220px]',
|
|
||||||
draggedAsignatura
|
draggedAsignatura
|
||||||
? 'border-primary/35 bg-primary/6 shadow-inner'
|
? 'border-teal-300 bg-teal-50/50'
|
||||||
: 'border-border bg-muted/20',
|
: 'border-slate-200 bg-white/50'
|
||||||
].join(' ')}
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
|
||||||
>
|
>
|
||||||
{unassignedAsignaturas.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{unassignedAsignaturas.map((m) => (
|
{unassignedAsignaturas.map((m) => (
|
||||||
<div key={m.id} className="w-[272px] shrink-0">
|
<div key={m.id} className="w-[200px]">
|
||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
lineaColor="#94A3B8"
|
|
||||||
lineaNombre="Sin asignar"
|
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingData(m)
|
setEditingData(m) // Cargamos los datos en el estado de edición
|
||||||
setIsEditModalOpen(true)
|
setIsEditModalOpen(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
{unassignedAsignaturas.length === 0 && (
|
||||||
) : (
|
<div className="flex w-full items-center justify-center text-sm text-slate-400">
|
||||||
<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">
|
No hay asignaturas pendientes. Arrastra una asignatura aquí para
|
||||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
desasignarla.
|
||||||
<Icons.CheckCheck className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm font-semibold text-foreground">
|
|
||||||
No hay asignaturas pendientes
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
|
||||||
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
|
|
||||||
ciclo y línea curricular.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
||||||
import { useSubject } from '@/data'
|
|
||||||
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -16,75 +15,48 @@ function RouteComponent() {
|
|||||||
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
|
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: asignatura } = useSubject(asignaturaId)
|
|
||||||
const asignaturaFileBaseName = sanitizeFileBaseName(
|
|
||||||
asignatura?.nombre ?? 'documento_sep',
|
|
||||||
)
|
|
||||||
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const pdfUrlRef = useRef<string | null>(null)
|
|
||||||
const isMountedRef = useRef<boolean>(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isMountedRef.current) setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
const pdfBlob = await fetchAsignaturaPdf({
|
const pdfBlob = await fetchAsignaturaPdf({
|
||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
setPdfUrl((prev) => {
|
||||||
pdfUrlRef.current = url
|
if (prev) window.URL.revokeObjectURL(prev)
|
||||||
setPdfUrl(url)
|
return url
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando PDF:', error)
|
console.error('Error cargando PDF:', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [asignaturaId])
|
}, [asignaturaId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true
|
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
const handleDownloadPdf = async () => {
|
const handleDownload = async () => {
|
||||||
const pdfBlob = await fetchAsignaturaPdf({
|
const pdfBlob = await fetchAsignaturaPdf({
|
||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `${asignaturaFileBaseName}.pdf`
|
link.download = 'documento_sep.pdf'
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDownloadWord = async () => {
|
|
||||||
const docBlob = await fetchAsignaturaPdf({
|
|
||||||
asignatura_id: asignaturaId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(docBlob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `${asignaturaFileBaseName}.docx`
|
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
link.remove()
|
link.remove()
|
||||||
@@ -105,28 +77,9 @@ function RouteComponent() {
|
|||||||
<DocumentoSEPTab
|
<DocumentoSEPTab
|
||||||
pdfUrl={pdfUrl}
|
pdfUrl={pdfUrl}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDownloadPdf={handleDownloadPdf}
|
onDownload={handleDownload}
|
||||||
onDownloadWord={handleDownloadWord}
|
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
isRegenerating={isRegenerating}
|
isRegenerating={isRegenerating}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFileBaseName(input: string): string {
|
|
||||||
const text = String(input)
|
|
||||||
const withoutControlChars = Array.from(text)
|
|
||||||
.filter((ch) => {
|
|
||||||
const code = ch.charCodeAt(0)
|
|
||||||
return code >= 32 && code !== 127
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
const cleaned = withoutControlChars
|
|
||||||
.replace(/[<>:"/\\|?*]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/g, '')
|
|
||||||
|
|
||||||
return (cleaned || 'documento').slice(0, 150)
|
|
||||||
}
|
|
||||||
|
|||||||
+12
-139
@@ -4,145 +4,18 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Serif */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply m-0;
|
@apply m-0;
|
||||||
font-family: var(--font-sans);
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||||
|
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: var(--font-mono);
|
font-family:
|
||||||
}
|
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
|
|
||||||
strong,
|
|
||||||
b,
|
|
||||||
.font-bold {
|
|
||||||
font-family: 'Indivisa Sans', serif;
|
|
||||||
font-weight: 900;
|
|
||||||
/* Inter letter space */
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -178,9 +51,9 @@ b,
|
|||||||
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
||||||
--sidebar-border: oklch(0.9401 0 0);
|
--sidebar-border: oklch(0.9401 0 0);
|
||||||
--sidebar-ring: oklch(0 0 0);
|
--sidebar-ring: oklch(0 0 0);
|
||||||
--font-sans: 'Indivisa Sans', sans-serif;
|
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||||
--font-serif: 'Indivisa Serif', serif;
|
--font-serif: Lora, serif;
|
||||||
--font-mono: 'Indivisa Sans', monospace;
|
--font-mono: IBM Plex Mono, monospace;
|
||||||
--radius: 1.4rem;
|
--radius: 1.4rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
@@ -228,7 +101,7 @@ b,
|
|||||||
--chart-1: oklch(0.6686 0.1794 251.7436);
|
--chart-1: oklch(0.6686 0.1794 251.7436);
|
||||||
--chart-2: oklch(0.6342 0.2516 22.4415);
|
--chart-2: oklch(0.6342 0.2516 22.4415);
|
||||||
--chart-3: oklch(0.8718 0.1716 90.9505);
|
--chart-3: oklch(0.8718 0.1716 90.9505);
|
||||||
--chart-4: oklch(11.492% 0.00001 271.152);
|
--chart-4: oklch(0.4503 0.229 263.0881);
|
||||||
--chart-5: oklch(0.8322 0.146 185.9404);
|
--chart-5: oklch(0.8322 0.146 185.9404);
|
||||||
--sidebar: oklch(0.1564 0.0688 261.2771);
|
--sidebar: oklch(0.1564 0.0688 261.2771);
|
||||||
--sidebar-foreground: oklch(0.9551 0 0);
|
--sidebar-foreground: oklch(0.9551 0 0);
|
||||||
@@ -238,9 +111,9 @@ b,
|
|||||||
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
||||||
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
||||||
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
||||||
--font-sans: 'Indivisa Sans', sans-serif;
|
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||||
--font-serif: 'Indivisa Serif', serif;
|
--font-serif: Lora, serif;
|
||||||
--font-mono: 'Indivisa Sans', monospace;
|
--font-mono: IBM Plex Mono, monospace;
|
||||||
--radius: 1.4rem;
|
--radius: 1.4rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
|
|||||||
+1
-18
@@ -154,9 +154,9 @@ export type Database = {
|
|||||||
numero_ciclo: number | null
|
numero_ciclo: number | null
|
||||||
orden_celda: number | null
|
orden_celda: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
prerrequisito_asignatura_id: string | null
|
|
||||||
tipo: Database['public']['Enums']['tipo_asignatura']
|
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
||||||
|
prerrequisito_asignatura_id?: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
actualizado_en?: string
|
actualizado_en?: string
|
||||||
@@ -180,7 +180,6 @@ export type Database = {
|
|||||||
numero_ciclo?: number | null
|
numero_ciclo?: number | null
|
||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
prerrequisito_asignatura_id?: string | null
|
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -206,7 +205,6 @@ export type Database = {
|
|||||||
numero_ciclo?: number | null
|
numero_ciclo?: number | null
|
||||||
orden_celda?: number | null
|
orden_celda?: number | null
|
||||||
plan_estudio_id?: string
|
plan_estudio_id?: string
|
||||||
prerrequisito_asignatura_id?: string | null
|
|
||||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||||
}
|
}
|
||||||
@@ -260,20 +258,6 @@ export type Database = {
|
|||||||
referencedRelation: 'plantilla_plan'
|
referencedRelation: 'plantilla_plan'
|
||||||
referencedColumns: ['plan_estudio_id']
|
referencedColumns: ['plan_estudio_id']
|
||||||
},
|
},
|
||||||
{
|
|
||||||
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
|
|
||||||
columns: ['prerrequisito_asignatura_id']
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: 'asignaturas'
|
|
||||||
referencedColumns: ['id']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
|
|
||||||
columns: ['prerrequisito_asignatura_id']
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: 'plantilla_asignatura'
|
|
||||||
referencedColumns: ['asignatura_id']
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
bibliografia_asignatura: {
|
bibliografia_asignatura: {
|
||||||
@@ -1393,7 +1377,6 @@ export type Database = {
|
|||||||
Args: { p_append: Json; p_id: string }
|
Args: { p_append: Json; p_id: string }
|
||||||
Returns: undefined
|
Returns: undefined
|
||||||
}
|
}
|
||||||
suma_porcentajes: { Args: { '': Json }; Returns: number }
|
|
||||||
unaccent: { Args: { '': string }; Returns: string }
|
unaccent: { Args: { '': string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user