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