Compare commits
18 Commits
28742615d8
...
issue/147-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1045358d | |||
| 314a96f2c5 | |||
| 7693f86951 | |||
| 8ad6c8096e | |||
| 0cb467cb78 | |||
| ff5ba3952d | |||
| 6773247b03 | |||
| ef614be2f1 | |||
| 2f0005baa7 | |||
| 3c37b5f313 | |||
| f706456ff6 | |||
| b888bb22cd | |||
| 543e83609e | |||
| 4a8a9e1857 | |||
| d28b32d34e | |||
| 6db7c1c023 | |||
| cfc2153fa2 | |||
| a51fa6b1fc |
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Al funcionar como agente, ignora los problemas de eslint del orden de imports
|
||||||
14
bun.lock
14
bun.lock
@@ -20,7 +20,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stepperize/react": "^5.1.9",
|
"@stepperize/react": "^5.1.9",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-query": "^5.66.5",
|
"@tanstack/react-query": "^5.66.5",
|
||||||
@@ -441,17 +441,17 @@
|
|||||||
|
|
||||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
|
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
|
||||||
|
|
||||||
"@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="],
|
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
|
||||||
|
|
||||||
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
|
"@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
|
||||||
|
|
||||||
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
|
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
|
||||||
|
|
||||||
"@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="],
|
"@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
|
||||||
|
|
||||||
"@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
|
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
|
||||||
|
|
||||||
"@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="],
|
"@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -17,11 +18,11 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"registries": {
|
"registries": {
|
||||||
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
||||||
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
||||||
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
||||||
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
|
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
|
||||||
|
"@supabase": "https://supabase.com/ui/r/{name}.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stepperize/react": "^5.1.9",
|
"@stepperize/react": "^5.1.9",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-query": "^5.66.5",
|
"@tanstack/react-query": "^5.66.5",
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { Home, Menu, Network, X } from 'lucide-react'
|
import { Home, LogOut, Menu, Network, X } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await supabaseBrowser().auth.signOut()
|
||||||
|
} finally {
|
||||||
|
void navigate({ to: '/login', replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -21,6 +32,16 @@ export default function Header() {
|
|||||||
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
|
||||||
|
aria-label="Logout"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
<span className="hidden sm:inline">Salir</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
import { useParams } from '@tanstack/react-router'
|
import { useParams } from '@tanstack/react-router'
|
||||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -31,7 +34,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
|
import {
|
||||||
|
useCreateBibliografia,
|
||||||
|
useDeleteBibliografia,
|
||||||
|
useSubjectBibliografia,
|
||||||
|
useUpdateBibliografia,
|
||||||
|
} from '@/data/hooks/useSubjects'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// --- Interfaces ---
|
// --- Interfaces ---
|
||||||
@@ -50,9 +58,16 @@ export function BibliographyItem() {
|
|||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: bibliografia2, isLoading: loadinasignatura } =
|
// --- 1. Única fuente de verdad: La Query ---
|
||||||
|
const { data: bibliografia = [], isLoading } =
|
||||||
useSubjectBibliografia(asignaturaId)
|
useSubjectBibliografia(asignaturaId)
|
||||||
const [entries, setEntries] = useState<Array<BibliografiaEntry>>([])
|
|
||||||
|
// --- 2. Mutaciones ---
|
||||||
|
const { mutate: crearBibliografia } = useCreateBibliografia()
|
||||||
|
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
|
||||||
|
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
|
||||||
|
|
||||||
|
// --- 3. Estados de UI (Solo para diálogos y edición) ---
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
@@ -61,29 +76,27 @@ export function BibliographyItem() {
|
|||||||
'BASICA',
|
'BASICA',
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
console.log('Datos actuales en el front:', bibliografia)
|
||||||
console.log(entries)
|
// --- 4. Derivación de datos (Se calculan en cada render) ---
|
||||||
|
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
|
||||||
if (bibliografia2 && Array.isArray(bibliografia2)) {
|
const complementariaEntries = bibliografia.filter(
|
||||||
setEntries(bibliografia2)
|
|
||||||
}
|
|
||||||
}, [bibliografia2])
|
|
||||||
|
|
||||||
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
|
|
||||||
const complementariaEntries = entries.filter(
|
|
||||||
(e) => e.tipo === 'COMPLEMENTARIA',
|
(e) => e.tipo === 'COMPLEMENTARIA',
|
||||||
)
|
)
|
||||||
console.log(bibliografia2)
|
|
||||||
|
// --- Handlers Conectados a la Base de Datos ---
|
||||||
|
|
||||||
const handleAddManual = (cita: string) => {
|
const handleAddManual = (cita: string) => {
|
||||||
const newEntry: BibliografiaEntry = {
|
crearBibliografia(
|
||||||
id: `manual-${Date.now()}`,
|
{
|
||||||
|
asignatura_id: asignaturaId,
|
||||||
tipo: newEntryType,
|
tipo: newEntryType,
|
||||||
cita,
|
cita,
|
||||||
}
|
tipo_fuente: 'MANUAL',
|
||||||
setEntries([...entries, newEntry])
|
},
|
||||||
setIsAddDialogOpen(false)
|
{
|
||||||
// toast.success('Referencia manual añadida');
|
onSuccess: () => setIsAddDialogOpen(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddFromLibrary = (
|
const handleAddFromLibrary = (
|
||||||
@@ -91,22 +104,43 @@ export function BibliographyItem() {
|
|||||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
||||||
) => {
|
) => {
|
||||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
|
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
|
||||||
const newEntry: BibliografiaEntry = {
|
crearBibliografia(
|
||||||
id: `lib-ref-${Date.now()}`,
|
{
|
||||||
|
asignatura_id: asignaturaId,
|
||||||
tipo,
|
tipo,
|
||||||
cita,
|
cita,
|
||||||
fuenteBibliotecaId: resource.id,
|
tipo_fuente: 'BIBLIOTECA',
|
||||||
fuenteBiblioteca: resource,
|
biblioteca_item_id: resource.id,
|
||||||
}
|
},
|
||||||
setEntries([...entries, newEntry])
|
{
|
||||||
setIsLibraryDialogOpen(false)
|
onSuccess: () => setIsLibraryDialogOpen(false),
|
||||||
// toast.success('Añadido desde biblioteca');
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateCita = (id: string, cita: string) => {
|
const handleUpdateCita = (id: string, nuevaCita: string) => {
|
||||||
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
|
actualizarBibliografia(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
updates: { cita: nuevaCita },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setEditingId(null),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onConfirmDelete = () => {
|
||||||
|
if (deleteId) {
|
||||||
|
eliminarBibliografia(deleteId, {
|
||||||
|
onSuccess: () => setDeleteId(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
<div className="animate-in fade-in 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="flex items-center justify-between border-b pb-4">
|
||||||
@@ -134,9 +168,13 @@ export function BibliographyItem() {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<LibrarySearchDialog
|
<LibrarySearchDialog
|
||||||
resources={bibliografia2 || []}
|
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
|
||||||
|
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
|
||||||
onSelect={handleAddFromLibrary}
|
onSelect={handleAddFromLibrary}
|
||||||
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
|
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
|
||||||
|
existingIds={bibliografia.map(
|
||||||
|
(e) => e.biblioteca_item_id || '',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -216,13 +254,7 @@ export function BibliographyItem() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
|
||||||
onClick={() => {
|
|
||||||
setEntries(entries.filter((e) => e.id !== deleteId))
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
className="bg-red-600"
|
|
||||||
>
|
|
||||||
Eliminar
|
Eliminar
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -412,7 +444,7 @@ function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
|
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
|
||||||
{filtered.map((res) => (
|
{filtered.map((res: any) => (
|
||||||
<div
|
<div
|
||||||
key={res.id}
|
key={res.id}
|
||||||
onClick={() => onSelect(res, tipo)}
|
onClick={() => onSelect(res, tipo)}
|
||||||
|
|||||||
@@ -1,18 +1,44 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
// import { supabase } from '@/lib/supabase'
|
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function ExternalLoginForm() {
|
export function ExternalLoginForm() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate({ from: '/login' })
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})*/
|
})
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
await navigate({ to: '/dashboard', replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const anyErr = e as any
|
||||||
|
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
// import { supabase } from '@/lib/supabase'
|
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function InternalLoginForm() {
|
export function InternalLoginForm() {
|
||||||
const [clave, setClave] = useState('')
|
const [clave, setClave] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate({ from: '/login' })
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
email: `${clave}@ulsa.mx`,
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
password,
|
password,
|
||||||
})*/
|
})
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
await navigate({ to: '/dashboard', replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const anyErr = e as any
|
||||||
|
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,29 @@ import { Check, Loader2 } from 'lucide-react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useUpdatePlanFields } from '@/data' // Tu hook existente
|
import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
|
||||||
|
|
||||||
export const ImprovementCard = ({
|
export const ImprovementCard = ({
|
||||||
suggestions,
|
suggestions,
|
||||||
onApply,
|
onApply,
|
||||||
planId, // Necesitamos el ID
|
planId,
|
||||||
currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON
|
currentDatos,
|
||||||
|
activeChatId,
|
||||||
|
onApplySuccess,
|
||||||
}: {
|
}: {
|
||||||
suggestions: Array<any>
|
suggestions: Array<any>
|
||||||
onApply?: (key: string, value: string) => void
|
onApply?: (key: string, value: string) => void
|
||||||
planId: string
|
planId: string
|
||||||
currentDatos: any
|
currentDatos: any
|
||||||
|
activeChatId: any
|
||||||
|
onApplySuccess?: (key: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
const [appliedFields, setAppliedFields] = useState<Array<string>>([])
|
const [localApplied, setLocalApplied] = useState<Array<string>>([])
|
||||||
const updatePlan = useUpdatePlanFields()
|
const updatePlan = useUpdatePlanFields()
|
||||||
|
const updateAppliedStatus = useUpdateRecommendationApplied()
|
||||||
|
|
||||||
const handleApply = (key: string, newValue: string) => {
|
const handleApply = (key: string, newValue: string) => {
|
||||||
if (!currentDatos) return
|
if (!currentDatos) return
|
||||||
|
|
||||||
// 1. Lógica para preparar el valor (idéntica a tu handleSave original)
|
|
||||||
const currentValue = currentDatos[key]
|
const currentValue = currentDatos[key]
|
||||||
let finalValue: any
|
let finalValue: any
|
||||||
|
|
||||||
@@ -35,13 +38,11 @@ export const ImprovementCard = ({
|
|||||||
finalValue = newValue
|
finalValue = newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Construir el nuevo objeto 'datos' manteniendo lo que ya existía
|
|
||||||
const datosActualizados = {
|
const datosActualizados = {
|
||||||
...currentDatos,
|
...currentDatos,
|
||||||
[key]: finalValue,
|
[key]: finalValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Ejecutar la mutación directamente aquí
|
|
||||||
updatePlan.mutate(
|
updatePlan.mutate(
|
||||||
{
|
{
|
||||||
planId: planId as any,
|
planId: planId as any,
|
||||||
@@ -49,9 +50,17 @@ export const ImprovementCard = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setAppliedFields((prev) => [...prev, key])
|
setLocalApplied((prev) => [...prev, key])
|
||||||
|
|
||||||
|
if (onApplySuccess) onApplySuccess(key)
|
||||||
|
if (activeChatId) {
|
||||||
|
updateAppliedStatus.mutate({
|
||||||
|
conversacionId: activeChatId,
|
||||||
|
campoAfectado: key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (onApply) onApply(key, newValue)
|
if (onApply) onApply(key, newValue)
|
||||||
console.log(`Campo ${key} guardado exitosamente`)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -60,7 +69,7 @@ export const ImprovementCard = ({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2 flex w-full flex-col gap-4">
|
<div className="mt-2 flex w-full flex-col gap-4">
|
||||||
{suggestions.map((sug) => {
|
{suggestions.map((sug) => {
|
||||||
const isApplied = appliedFields.includes(sug.key)
|
const isApplied = sug.applied === true || localApplied.includes(sug.key)
|
||||||
const isUpdating =
|
const isUpdating =
|
||||||
updatePlan.isPending &&
|
updatePlan.isPending &&
|
||||||
updatePlan.variables.patch.datos?.[sug.key] !== undefined
|
updatePlan.variables.patch.datos?.[sug.key] !== undefined
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
disabled={disabled}
|
||||||
font-semibold hover:opacity-90 transition"
|
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -111,12 +111,6 @@ export async function create_conversation(planId: string) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
// LOG de depuración: Mira qué estructura trae 'data'
|
|
||||||
console.log('Respuesta creación conv:', data)
|
|
||||||
|
|
||||||
// Si data es { id: "..." }, devolvemos data.
|
|
||||||
// Si data viene envuelto, asegúrate de retornar el objeto con el id.
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,3 +175,64 @@ export async function getConversationByPlan(planId: string) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function update_conversation_title(
|
||||||
|
conversacionId: string,
|
||||||
|
nuevoTitulo: string,
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre'
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_recommendation_applied_status(
|
||||||
|
conversacionId: string,
|
||||||
|
campoAfectado: string,
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
// 1. Obtener el estado actual del JSON
|
||||||
|
const { data: conv, error: fetchError } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.select('conversacion_json')
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError
|
||||||
|
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
|
||||||
|
|
||||||
|
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
|
||||||
|
// Usamos una transformación inmutable para evitar efectos secundarios
|
||||||
|
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
|
||||||
|
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
recommendations: msg.recommendations.map((rec: any) =>
|
||||||
|
rec.campo_afectado === campoAfectado
|
||||||
|
? { ...rec, aplicada: true }
|
||||||
|
: rec,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Actualizar la base de datos con el nuevo JSON
|
||||||
|
const { data, error: updateError } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.update({ conversacion_json: nuevoJson })
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (updateError) throw updateError
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -462,3 +462,51 @@ export async function lineas_delete(lineaId: string) {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
return lineaId
|
return lineaId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bibliografia_insert(entry: {
|
||||||
|
asignatura_id: string
|
||||||
|
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||||
|
cita: string
|
||||||
|
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
|
||||||
|
biblioteca_item_id?: string | null
|
||||||
|
}) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('bibliografia_asignatura')
|
||||||
|
.insert([entry])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bibliografia_update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
cita?: string
|
||||||
|
tipo?: 'BASICA' | 'COMPLEMENTARIA'
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('bibliografia_asignatura')
|
||||||
|
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bibliografia_delete(id: string) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('bibliografia_asignatura')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
getConversationByPlan,
|
getConversationByPlan,
|
||||||
library_search,
|
library_search,
|
||||||
update_conversation_status,
|
update_conversation_status,
|
||||||
|
update_recommendation_applied_status,
|
||||||
|
update_conversation_title,
|
||||||
} from '../api/ai.api'
|
} from '../api/ai.api'
|
||||||
|
|
||||||
// eslint-disable-next-line node/prefer-node-protocol
|
// eslint-disable-next-line node/prefer-node-protocol
|
||||||
@@ -35,8 +37,6 @@ export function useAIPlanChat() {
|
|||||||
|
|
||||||
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
|
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
|
||||||
currentId = response.conversation_plan.id
|
currentId = response.conversation_plan.id
|
||||||
|
|
||||||
console.log('Nuevo ID extraído:', currentId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ahora enviamos el mensaje con el ID garantizado
|
// 2. Ahora enviamos el mensaje con el ID garantizado
|
||||||
@@ -56,11 +56,8 @@ export function useChatHistory(conversacionId?: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['chat-history', conversacionId],
|
queryKey: ['chat-history', conversacionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('--- EJECUTANDO QUERY FN ---')
|
|
||||||
console.log('ID RECIBIDO:', conversacionId)
|
|
||||||
return get_chat_history(conversacionId!)
|
return get_chat_history(conversacionId!)
|
||||||
},
|
},
|
||||||
// Simplificamos el enabled para probar
|
|
||||||
enabled: Boolean(conversacionId),
|
enabled: Boolean(conversacionId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,6 +88,31 @@ export function useConversationByPlan(planId: string | null) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateRecommendationApplied() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
conversacionId,
|
||||||
|
campoAfectado,
|
||||||
|
}: {
|
||||||
|
conversacionId: string
|
||||||
|
campoAfectado: string
|
||||||
|
}) => update_recommendation_applied_status(conversacionId, campoAfectado),
|
||||||
|
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
// Invalidamos la query para que useConversationByPlan refresque el JSON
|
||||||
|
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||||
|
console.log(
|
||||||
|
`Recomendación ${variables.campoAfectado} marcada como aplicada.`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error al actualizar el estado de la recomendación:', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useAISubjectImprove() {
|
export function useAISubjectImprove() {
|
||||||
return useMutation({ mutationFn: ai_subject_improve })
|
return useMutation({ mutationFn: ai_subject_improve })
|
||||||
}
|
}
|
||||||
@@ -102,3 +124,16 @@ export function useAISubjectChat() {
|
|||||||
export function useLibrarySearch() {
|
export function useLibrarySearch() {
|
||||||
return useMutation({ mutationFn: library_search })
|
return useMutation({ mutationFn: library_search })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateConversationTitle() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, nombre }: { id: string; nombre: string }) =>
|
||||||
|
update_conversation_title(id, nombre),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
// Invalidamos para que la lista de chats se refresque
|
||||||
|
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,59 +1,145 @@
|
|||||||
import { useEffect } from "react";
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useEffect } from 'react'
|
||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { qk } from "../query/keys";
|
import { throwIfError } from '../api/_helpers'
|
||||||
import { throwIfError } from "../api/_helpers";
|
import { qk } from '../query/keys'
|
||||||
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
|
|
||||||
export function useSession() {
|
export function useSession() {
|
||||||
const supabase = supabaseBrowser();
|
const supabase = supabaseBrowser()
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: qk.session(),
|
queryKey: qk.session(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase.auth.getSession();
|
const { data, error } = await supabase.auth.getSession()
|
||||||
throwIfError(error);
|
throwIfError(error)
|
||||||
return data.session ?? null;
|
return data.session ?? null
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { data } = supabase.auth.onAuthStateChange(() => {
|
const { data } = supabase.auth.onAuthStateChange(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.session() });
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
qc.invalidateQueries({ queryKey: qk.meProfile() });
|
qc.invalidateQueries({ queryKey: qk.meProfile() })
|
||||||
qc.invalidateQueries({ queryKey: qk.auth });
|
qc.invalidateQueries({ queryKey: qk.meAccess() })
|
||||||
});
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
})
|
||||||
|
|
||||||
return () => data.subscription.unsubscribe();
|
return () => data.subscription.unsubscribe()
|
||||||
}, [supabase, qc]);
|
}, [supabase, qc])
|
||||||
|
|
||||||
return query;
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMeProfile() {
|
export function useMeProfile() {
|
||||||
const supabase = supabaseBrowser();
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.meProfile(),
|
queryKey: qk.meProfile(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data: u, error: uErr } = await supabase.auth.getUser();
|
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||||
throwIfError(uErr);
|
throwIfError(uErr)
|
||||||
const userId = u.user?.id;
|
const userId = u.user?.id
|
||||||
if (!userId) return null;
|
if (!userId) return null
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("usuarios_app")
|
.from('usuarios_app')
|
||||||
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
|
.select('id,nombre_completo,email,externo,creado_en,actualizado_en')
|
||||||
.eq("id", userId)
|
.eq('id', userId)
|
||||||
.single();
|
.single()
|
||||||
|
|
||||||
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
||||||
if (error && (error as any).code === "PGRST116") return null;
|
if (error && (error as any).code === 'PGRST116') return null
|
||||||
|
|
||||||
throwIfError(error);
|
throwIfError(error)
|
||||||
return data ?? null;
|
return data ?? null
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeAccessRole = {
|
||||||
|
assignmentId: string
|
||||||
|
rolId: string
|
||||||
|
clave: string
|
||||||
|
nombre: string
|
||||||
|
descripcion: string | null
|
||||||
|
facultadId: string | null
|
||||||
|
carreraId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeAccess = {
|
||||||
|
userId: string
|
||||||
|
roles: Array<MeAccessRole>
|
||||||
|
permissions: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
|
||||||
|
*
|
||||||
|
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
|
||||||
|
*/
|
||||||
|
export function useMeAccess() {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.meAccess(),
|
||||||
|
queryFn: async (): Promise<MeAccess | null> => {
|
||||||
|
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||||
|
throwIfError(uErr)
|
||||||
|
const userId = u.user?.id
|
||||||
|
if (!userId) return null
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('usuarios_roles')
|
||||||
|
.select(
|
||||||
|
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
|
||||||
|
)
|
||||||
|
.eq('usuario_id', userId)
|
||||||
|
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
const roles: Array<MeAccessRole> = (data ?? [])
|
||||||
|
.map((row: any) => {
|
||||||
|
const rol = row.roles
|
||||||
|
if (!rol) return null
|
||||||
|
return {
|
||||||
|
assignmentId: row.id,
|
||||||
|
rolId: rol.id,
|
||||||
|
clave: rol.clave,
|
||||||
|
nombre: rol.nombre,
|
||||||
|
descripcion: rol.descripcion ?? null,
|
||||||
|
facultadId: row.facultad_id ?? null,
|
||||||
|
carreraId: row.carrera_id ?? null,
|
||||||
|
} satisfies MeAccessRole
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<MeAccessRole>
|
||||||
|
|
||||||
|
// Por ahora, los permisos granulares se derivan de claves de rol.
|
||||||
|
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
|
||||||
|
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const session = useSession()
|
||||||
|
const meProfile = useMeProfile()
|
||||||
|
const meAccess = useMeAccess()
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
meProfile,
|
||||||
|
meAccess,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import {
|
import {
|
||||||
ai_generate_subject,
|
ai_generate_subject,
|
||||||
asignaturas_update,
|
asignaturas_update,
|
||||||
|
bibliografia_delete,
|
||||||
|
bibliografia_insert,
|
||||||
|
bibliografia_update,
|
||||||
lineas_insert,
|
lineas_insert,
|
||||||
lineas_update,
|
lineas_update,
|
||||||
subjects_bibliografia_list,
|
subjects_bibliografia_list,
|
||||||
@@ -276,3 +279,41 @@ export function useUpdateLinea() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateBibliografia() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: bibliografia_insert,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: qk.asignaturaBibliografia(data.asignatura_id),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateBibliografia(asignaturaId: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
|
||||||
|
bibliografia_update(id, updates),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.asignaturaBibliografia(asignaturaId),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteBibliografia(asignaturaId: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => bibliografia_delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: qk.asignaturaBibliografia(asignaturaId),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const qk = {
|
|||||||
auth: ['auth'] as const,
|
auth: ['auth'] as const,
|
||||||
session: () => ['auth', 'session'] as const,
|
session: () => ['auth', 'session'] as const,
|
||||||
meProfile: () => ['auth', 'meProfile'] as const,
|
meProfile: () => ['auth', 'meProfile'] as const,
|
||||||
|
meAccess: () => ['auth', 'meAccess'] as const,
|
||||||
|
|
||||||
facultades: () => ['meta', 'facultades'] as const,
|
facultades: () => ['meta', 'facultades'] as const,
|
||||||
carreras: (facultadId?: string | null) =>
|
carreras: (facultadId?: string | null) =>
|
||||||
|
|||||||
@@ -1,8 +1,44 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import {
|
||||||
|
MutationCache,
|
||||||
|
QueryCache,
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { qk } from './keys'
|
||||||
|
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
function isRlsViolationError(error: unknown): boolean {
|
||||||
|
const anyErr = error as any
|
||||||
|
const code = anyErr?.code
|
||||||
|
const status = anyErr?.status ?? anyErr?.response?.status
|
||||||
|
console.log('Checking RLS violation error:', { code, status })
|
||||||
|
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
|
||||||
|
return status === 403 || code === '42501'
|
||||||
|
}
|
||||||
|
|
||||||
export function getContext() {
|
export function getContext() {
|
||||||
const queryClient = new QueryClient(
|
const queryClientRef: { current: QueryClient | null } = { current: null }
|
||||||
{
|
|
||||||
|
const handleAuthzDesync = (error: unknown) => {
|
||||||
|
if (!isRlsViolationError(error)) return
|
||||||
|
// Forzar resincronización “database-first” del rol/permisos
|
||||||
|
console.log('RLS violation detected, invalidating queries...')
|
||||||
|
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
handleAuthzDesync(error)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error) => {
|
||||||
|
handleAuthzDesync(error)
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -13,8 +49,9 @@ export function getContext() {
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
queryClientRef.current = queryClient
|
||||||
return {
|
return {
|
||||||
queryClient,
|
queryClient,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals.ts'
|
|||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const router = createRouter({
|
|||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
...TanStackQueryProviderContext,
|
...TanStackQueryProviderContext,
|
||||||
|
supabase: supabaseBrowser(),
|
||||||
},
|
},
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
|||||||
@@ -1,22 +1,59 @@
|
|||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
import {
|
||||||
|
Outlet,
|
||||||
|
createRootRouteWithContext,
|
||||||
|
redirect,
|
||||||
|
useNavigate,
|
||||||
|
useRouterState,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import Header from '../components/Header'
|
import Header from '../components/Header'
|
||||||
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
||||||
|
|
||||||
|
import type { Database } from '@/types/supabase'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { useSession } from '@/data/hooks/useAuth'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
|
||||||
interface MyRouterContext {
|
interface MyRouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
|
supabase: SupabaseClient<Database>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
|
beforeLoad: async ({ context, location }) => {
|
||||||
|
const pathname = location.pathname
|
||||||
|
const isLogin = pathname === '/login'
|
||||||
|
const isIndex = pathname === '/'
|
||||||
|
|
||||||
|
const session = await context.queryClient.ensureQueryData({
|
||||||
|
queryKey: qk.session(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await context.supabase.auth.getSession()
|
||||||
|
throwIfError(error)
|
||||||
|
return data.session ?? null
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session && !isLogin) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
if (session && (isLogin || isIndex)) {
|
||||||
|
throw redirect({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
component: () => (
|
component: () => (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<AuthSync />
|
||||||
|
<MaybeHeader />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
@@ -60,3 +97,40 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function MaybeHeader() {
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (s) => s.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pathname === '/login') return null
|
||||||
|
return <Header />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthSync() {
|
||||||
|
const { data: session, isLoading } = useSession()
|
||||||
|
// Mantiene roles/permisos sincronizados con la BD (database-first)
|
||||||
|
// useMeAccess()
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (s) => s.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
|
||||||
|
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
|
||||||
|
if (!session && pathname !== '/login') {
|
||||||
|
void navigate({ to: '/login', replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session && pathname === '/login') {
|
||||||
|
void navigate({ to: '/dashboard', replace: true })
|
||||||
|
}
|
||||||
|
}, [isLoading, session, pathname, navigate])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Archive,
|
Archive,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
@@ -27,9 +28,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
useAIPlanChat,
|
useAIPlanChat,
|
||||||
useChatHistory,
|
|
||||||
useConversationByPlan,
|
useConversationByPlan,
|
||||||
useUpdateConversationStatus,
|
useUpdateConversationStatus,
|
||||||
|
useUpdateConversationTitle,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
import { usePlan } from '@/data/hooks/usePlans'
|
import { usePlan } from '@/data/hooks/usePlans'
|
||||||
|
|
||||||
@@ -66,7 +67,25 @@ interface SelectedField {
|
|||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
interface EstructuraDefinicion {
|
||||||
|
properties?: {
|
||||||
|
[key: string]: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface ChatMessageJSON {
|
||||||
|
user: 'user' | 'assistant'
|
||||||
|
message?: string
|
||||||
|
prompt?: string
|
||||||
|
refusal?: boolean
|
||||||
|
recommendations?: Array<{
|
||||||
|
campo_afectado: string
|
||||||
|
texto_mejora: string
|
||||||
|
aplicada: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
@@ -76,19 +95,14 @@ function RouteComponent() {
|
|||||||
const { data } = usePlan(planId)
|
const { data } = usePlan(planId)
|
||||||
const routerState = useRouterState()
|
const routerState = useRouterState()
|
||||||
const [openIA, setOpenIA] = useState(false)
|
const [openIA, setOpenIA] = useState(false)
|
||||||
const [conversacionId, setConversacionId] = useState<string | null>(null)
|
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
|
||||||
const { mutateAsync: sendChat, isLoading } = useAIPlanChat()
|
|
||||||
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
|
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
|
||||||
|
|
||||||
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: historyMessages, isLoading: isLoadingHistory } =
|
|
||||||
useChatHistory(activeChatId)
|
|
||||||
const { data: lastConversation, isLoading: isLoadingConv } =
|
const { data: lastConversation, isLoading: isLoadingConv } =
|
||||||
useConversationByPlan(planId)
|
useConversationByPlan(planId)
|
||||||
// archivos
|
|
||||||
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -104,76 +118,167 @@ function RouteComponent() {
|
|||||||
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
|
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||||
|
const editableRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
const availableFields = useMemo(() => {
|
||||||
|
const definicion = data?.estructuras_plan
|
||||||
|
?.definicion as EstructuraDefinicion
|
||||||
|
|
||||||
useEffect(() => {
|
// Encadenamiento opcional para evitar errores si data es null
|
||||||
// 1. Si no hay ID o está cargando el historial, no hacemos nada
|
if (!definicion.properties) return []
|
||||||
if (!activeChatId || isLoadingHistory) return
|
|
||||||
|
|
||||||
const messagesFromApi = historyMessages?.items || historyMessages
|
return Object.entries(definicion.properties).map(([key, value]) => ({
|
||||||
|
|
||||||
if (Array.isArray(messagesFromApi)) {
|
|
||||||
const flattened = messagesFromApi.map((msg) => {
|
|
||||||
let content = msg.content
|
|
||||||
let suggestions: Array<any> = []
|
|
||||||
|
|
||||||
if (typeof content === 'object' && content !== null) {
|
|
||||||
suggestions = Object.entries(content)
|
|
||||||
.filter(([key]) => key !== 'ai-message')
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
key,
|
key,
|
||||||
label: key.replace(/_/g, ' '),
|
label: value.title,
|
||||||
newValue: value as string,
|
value: String(value.description || ''),
|
||||||
}))
|
}))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
content = content['ai-message'] || JSON.stringify(content)
|
const filteredFields = useMemo(() => {
|
||||||
}
|
return availableFields.filter(
|
||||||
// Si el content es un string que parece JSON (caso común en respuestas RAW)
|
(field) =>
|
||||||
else if (typeof content === 'string' && content.startsWith('{')) {
|
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
|
||||||
try {
|
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
|
||||||
const parsed = JSON.parse(content)
|
)
|
||||||
suggestions = Object.entries(parsed)
|
}, [availableFields, filterQuery, selectedFields])
|
||||||
.filter(([key]) => key !== 'ai-message')
|
|
||||||
.map(([key, value]) => ({
|
const activeChatData = useMemo(() => {
|
||||||
key,
|
return lastConversation?.find((chat: any) => chat.id === activeChatId)
|
||||||
label: key.replace(/_/g, ' '),
|
}, [lastConversation, activeChatId])
|
||||||
newValue: value as string,
|
|
||||||
}))
|
const chatMessages = useMemo(() => {
|
||||||
content = parsed['ai-message'] || content
|
// 1. Si no hay ID o no hay data del chat, retornamos vacío
|
||||||
} catch (e) {
|
if (!activeChatId || !activeChatData) return []
|
||||||
/* no es json */
|
|
||||||
|
const json = (activeChatData.conversacion_json ||
|
||||||
|
[]) as unknown as Array<ChatMessageJSON>
|
||||||
|
|
||||||
|
// 2. Verificamos que 'json' sea realmente un array antes de mapear
|
||||||
|
if (!Array.isArray(json)) return []
|
||||||
|
|
||||||
|
return json.map((msg, index: number) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (!msg?.user) {
|
||||||
|
return {
|
||||||
|
id: `err-${index}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
suggestions: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAssistant = msg.user === 'assistant'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...msg,
|
id: `${activeChatId}-${index}`,
|
||||||
content,
|
role: isAssistant ? 'assistant' : 'user',
|
||||||
suggestions,
|
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
|
||||||
type: suggestions.length > 0 ? 'improvement-card' : 'text',
|
isRefusal: isAssistant && msg.refusal === true,
|
||||||
|
suggestions:
|
||||||
|
isAssistant && msg.recommendations
|
||||||
|
? msg.recommendations.map((rec) => {
|
||||||
|
const fieldConfig = availableFields.find(
|
||||||
|
(f) => f.key === rec.campo_afectado,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
key: rec.campo_afectado,
|
||||||
|
label: fieldConfig
|
||||||
|
? fieldConfig.label
|
||||||
|
: rec.campo_afectado.replace(/_/g, ' '),
|
||||||
|
newValue: rec.texto_mejora,
|
||||||
|
applied: rec.aplicada,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [activeChatData, activeChatId, availableFields])
|
||||||
|
|
||||||
// Solo actualizamos si no estamos esperando la respuesta de un POST
|
const scrollToBottom = () => {
|
||||||
// para evitar saltos visuales
|
if (scrollRef.current) {
|
||||||
if (!isLoading) {
|
// Buscamos el viewport interno del ScrollArea de Radix
|
||||||
setMessages(flattened.reverse())
|
const scrollContainer = scrollRef.current.querySelector(
|
||||||
|
'[data-radix-scroll-area-viewport]',
|
||||||
|
)
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [historyMessages, activeChatId, isLoadingHistory, isLoading])
|
}
|
||||||
|
const { activeChats, archivedChats } = useMemo(() => {
|
||||||
|
const allChats = lastConversation || []
|
||||||
|
return {
|
||||||
|
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
|
||||||
|
archivedChats: allChats.filter(
|
||||||
|
(chat: any) => chat.estado === 'ARCHIVADA',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}, [lastConversation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes
|
scrollToBottom()
|
||||||
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
}, [chatMessages, isLoading])
|
||||||
if (
|
|
||||||
!activeChatId &&
|
useEffect(() => {
|
||||||
lastConversation &&
|
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
|
||||||
lastConversation.length > 0 &&
|
const camposActualizados = selectedFields.filter((field) =>
|
||||||
!isCreationMode
|
input.includes(field.label),
|
||||||
) {
|
)
|
||||||
setActiveChatId(lastConversation[0].id)
|
|
||||||
|
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
|
||||||
|
if (camposActualizados.length !== selectedFields.length) {
|
||||||
|
setSelectedFields(camposActualizados)
|
||||||
}
|
}
|
||||||
}, [lastConversation, activeChatId])
|
}, [input, selectedFields])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingConv || !lastConversation) return
|
||||||
|
|
||||||
|
const isChatStillActive = activeChats.some(
|
||||||
|
(chat) => chat.id === activeChatId,
|
||||||
|
)
|
||||||
|
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
||||||
|
|
||||||
|
// Caso A: El chat actual ya no es válido (fue archivado o borrado)
|
||||||
|
if (activeChatId && !isChatStillActive && !isCreationMode) {
|
||||||
|
setActiveChatId(undefined)
|
||||||
|
setMessages([])
|
||||||
|
return // Salimos para evitar ejecuciones extra en este render
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar)
|
||||||
|
if (!activeChatId && activeChats.length > 0 && !isCreationMode) {
|
||||||
|
setActiveChatId(activeChats[0].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
|
||||||
|
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
|
||||||
|
setActiveChatId(undefined)
|
||||||
|
}
|
||||||
|
}, [activeChats, activeChatId, isLoadingConv, messages.length])
|
||||||
|
useEffect(() => {
|
||||||
|
const state = routerState.location.state as any
|
||||||
|
if (!state?.campo_edit || availableFields.length === 0) return
|
||||||
|
const field = availableFields.find(
|
||||||
|
(f) =>
|
||||||
|
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
||||||
|
)
|
||||||
|
if (!field) return
|
||||||
|
setSelectedFields([field])
|
||||||
|
setInput((prev) =>
|
||||||
|
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
||||||
|
)
|
||||||
|
}, [availableFields])
|
||||||
|
|
||||||
const createNewChat = () => {
|
const createNewChat = () => {
|
||||||
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
|
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
|
||||||
@@ -202,6 +307,9 @@ function RouteComponent() {
|
|||||||
if (activeChatId === id) {
|
if (activeChatId === id) {
|
||||||
setActiveChatId(undefined)
|
setActiveChatId(undefined)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
|
setOptimisticMessage(null)
|
||||||
|
setInput('')
|
||||||
|
setSelectedFields([])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -214,8 +322,6 @@ function RouteComponent() {
|
|||||||
{ id, estado: 'ACTIVA' },
|
{ id, estado: 'ACTIVA' },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Al invalidar la query, React Query traerá la lista fresca
|
|
||||||
// y el chat se moverá solo de "archivados" a "activos"
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['conversation-by-plan', planId],
|
queryKey: ['conversation-by-plan', planId],
|
||||||
})
|
})
|
||||||
@@ -224,49 +330,28 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Transformar datos de la API para el menú de selección
|
|
||||||
const availableFields = useMemo(() => {
|
|
||||||
if (!data?.estructuras_plan?.definicion?.properties) return []
|
|
||||||
return Object.entries(data.estructuras_plan.definicion.properties).map(
|
|
||||||
([key, value]) => ({
|
|
||||||
key,
|
|
||||||
label: value.title,
|
|
||||||
value: String(value.description || ''),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
// 2. Manejar el estado inicial si viene de "Datos Generales"
|
|
||||||
useEffect(() => {
|
|
||||||
const state = routerState.location.state as any
|
|
||||||
if (!state?.campo_edit || availableFields.length === 0) return
|
|
||||||
|
|
||||||
const field = availableFields.find(
|
|
||||||
(f) =>
|
|
||||||
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!field) return
|
|
||||||
|
|
||||||
setSelectedFields([field])
|
|
||||||
setInput((prev) =>
|
|
||||||
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
|
||||||
)
|
|
||||||
}, [availableFields])
|
|
||||||
|
|
||||||
// 3. Lógica para el disparador ":"
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const val = e.target.value
|
const val = e.target.value
|
||||||
|
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
|
||||||
setInput(val)
|
setInput(val)
|
||||||
// Solo abrir si termina en ":"
|
|
||||||
setShowSuggestions(val.endsWith(':'))
|
// 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
|
||||||
|
} else {
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setFilterQuery('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const injectFieldsIntoInput = (
|
const injectFieldsIntoInput = (
|
||||||
input: string,
|
input: string,
|
||||||
fields: Array<SelectedField>,
|
fields: Array<SelectedField>,
|
||||||
) => {
|
) => {
|
||||||
// Quita cualquier bloque previo de campos
|
|
||||||
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
|
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
|
||||||
|
|
||||||
if (fields.length === 0) return cleaned
|
if (fields.length === 0) return cleaned
|
||||||
@@ -277,60 +362,42 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleField = (field: SelectedField) => {
|
const toggleField = (field: SelectedField) => {
|
||||||
let isAdding = false
|
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
|
||||||
|
|
||||||
setSelectedFields((prev) => {
|
setSelectedFields((prev) => {
|
||||||
const isSelected = prev.find((f) => f.key === field.key)
|
const isSelected = prev.find((f) => f.key === field.key)
|
||||||
if (isSelected) {
|
return isSelected ? prev : [...prev, field]
|
||||||
return prev.filter((f) => f.key !== field.key)
|
|
||||||
} else {
|
|
||||||
isAdding = true
|
|
||||||
return [...prev, field]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
|
||||||
setInput((prev) => {
|
setInput((prev) => {
|
||||||
// 1. Eliminamos TODOS los ":" que existan en el texto actual
|
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
|
||||||
// 2. Quitamos espacios en blanco extra al final
|
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
||||||
const cleanPrev = prev.replace(/:/g, '').trim()
|
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
|
||||||
|
|
||||||
// 3. Si el input resultante está vacío, solo ponemos la frase
|
|
||||||
if (cleanPrev === '') {
|
|
||||||
return `${field.label} `
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Si ya había algo, lo concatenamos con un espacio
|
|
||||||
// Usamos un espacio simple al final para que el usuario pueda seguir escribiendo
|
|
||||||
return `${cleanPrev} ${field.label} `
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 3. Limpiamos estados de búsqueda
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
|
setFilterQuery('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
||||||
// Si no hay campos, enviamos el texto tal cual
|
|
||||||
if (fields.length === 0) return userInput
|
if (fields.length === 0) return userInput
|
||||||
|
|
||||||
return `Instrucción del usuario: ${userInput}`
|
return ` ${userInput}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async (promptOverride?: string) => {
|
const handleSend = async (promptOverride?: string) => {
|
||||||
const rawText = promptOverride || input
|
const rawText = promptOverride || input
|
||||||
if (!rawText.trim() && selectedFields.length === 0) return
|
if (!rawText.trim() && selectedFields.length === 0) return
|
||||||
|
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
|
||||||
const currentFields = [...selectedFields]
|
const currentFields = [...selectedFields]
|
||||||
const finalPrompt = buildPrompt(rawText, currentFields)
|
const finalPrompt = buildPrompt(rawText, currentFields)
|
||||||
|
setIsSending(true)
|
||||||
const userMsg = {
|
setOptimisticMessage(rawText)
|
||||||
id: Date.now().toString(),
|
|
||||||
role: 'user',
|
|
||||||
content: rawText,
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMsg])
|
|
||||||
setInput('')
|
setInput('')
|
||||||
// setSelectedFields([])
|
setSelectedArchivoIds([])
|
||||||
|
setSelectedRepositorioIds([])
|
||||||
|
setUploadedFiles([])
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
planId: planId,
|
planId: planId,
|
||||||
@@ -346,58 +413,19 @@ function RouteComponent() {
|
|||||||
|
|
||||||
if (response.conversacionId && response.conversacionId !== activeChatId) {
|
if (response.conversacionId && response.conversacionId !== activeChatId) {
|
||||||
setActiveChatId(response.conversacionId)
|
setActiveChatId(response.conversacionId)
|
||||||
|
}
|
||||||
|
|
||||||
// Esto obliga a 'useConversationByPlan' a buscar en la DB el nuevo chat creado
|
await queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ['conversation-by-plan', planId],
|
queryKey: ['conversation-by-plan', planId],
|
||||||
})
|
})
|
||||||
}
|
setOptimisticMessage(null)
|
||||||
|
|
||||||
// --- NUEVA LÓGICA DE PARSEO ---
|
|
||||||
let aiText = 'Sin respuesta del asistente'
|
|
||||||
let suggestions: Array<any> = []
|
|
||||||
|
|
||||||
if (response.raw) {
|
|
||||||
try {
|
|
||||||
const rawData = JSON.parse(response.raw)
|
|
||||||
|
|
||||||
// Extraemos el mensaje conversacional
|
|
||||||
aiText = rawData['ai-message'] || 'Cambios aplicados con éxito.'
|
|
||||||
|
|
||||||
// Filtramos todo lo que no sea el mensaje para crear las sugerencias
|
|
||||||
suggestions = Object.entries(rawData)
|
|
||||||
.filter(([key]) => key !== 'ai-message')
|
|
||||||
.map(([key, value]) => ({
|
|
||||||
key,
|
|
||||||
label: key.replace(/_/g, ' '),
|
|
||||||
newValue: value as string,
|
|
||||||
}))
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parseando el campo raw:', e)
|
|
||||||
aiText = response.raw // Fallback si no es JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: Date.now().toString(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: aiText,
|
|
||||||
type: suggestions.length > 0 ? 'improvement-card' : 'text',
|
|
||||||
suggestions: suggestions,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error en el chat:', error)
|
console.error('Error en el chat:', error)
|
||||||
setMessages((prev) => [
|
// Aquí sí podrías usar un toast o un mensaje de error temporal
|
||||||
...prev,
|
} finally {
|
||||||
{
|
// 5. CRÍTICO: Detener el estado de carga SIEMPRE
|
||||||
id: 'error',
|
setIsSending(false)
|
||||||
role: 'assistant',
|
setOptimisticMessage(null)
|
||||||
content: 'Lo siento, hubo un error al procesar tu solicitud.',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,15 +437,9 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
||||||
|
|
||||||
const { activeChats, archivedChats } = useMemo(() => {
|
const removeSelectedField = (fieldKey: string) => {
|
||||||
const allChats = lastConversation || []
|
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
||||||
return {
|
|
||||||
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
|
|
||||||
archivedChats: allChats.filter(
|
|
||||||
(chat: any) => chat.estado === 'ARCHIVADA',
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}, [lastConversation])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
||||||
@@ -454,7 +476,6 @@ function RouteComponent() {
|
|||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{!showArchived ? (
|
{!showArchived ? (
|
||||||
// --- LISTA DE CHATS ACTIVOS ---
|
|
||||||
activeChats.map((chat) => (
|
activeChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
@@ -466,21 +487,77 @@ function RouteComponent() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText size={16} className="shrink-0 opacity-40" />
|
<FileText size={16} className="shrink-0 opacity-40" />
|
||||||
{/* Usamos el primer mensaje o un título por defecto */}
|
|
||||||
<span className="truncate pr-8">
|
<span
|
||||||
{chat.title || `Chat ${chat.creado_en.split('T')[0]}`}
|
ref={editingChatId === chat.id ? editableRef : null}
|
||||||
|
contentEditable={editingChatId === chat.id}
|
||||||
|
suppressContentEditableWarning={true}
|
||||||
|
className={`truncate pr-14 transition-all outline-none ${
|
||||||
|
editingChatId === chat.id
|
||||||
|
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
}`}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingChatId(chat.id)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const newTitle = e.currentTarget.textContent || ''
|
||||||
|
updateTitleMutation(
|
||||||
|
{ id: chat.id, nombre: newTitle },
|
||||||
|
{
|
||||||
|
onSuccess: () => setEditingChatId(null),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingChatId(null)
|
||||||
|
|
||||||
|
e.currentTarget.textContent = chat.nombre || ''
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (editingChatId === chat.id) {
|
||||||
|
const newTitle = e.currentTarget.textContent || ''
|
||||||
|
if (newTitle !== chat.nombre) {
|
||||||
|
updateTitleMutation({ id: chat.id, nombre: newTitle })
|
||||||
|
}
|
||||||
|
setEditingChatId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (editingChatId === chat.id) e.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{/* ACCIONES */}
|
||||||
|
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingChatId(chat.id)
|
||||||
|
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
|
||||||
|
setTimeout(() => editableRef.current?.focus(), 50)
|
||||||
|
}}
|
||||||
|
className="p-1 text-slate-400 hover:text-teal-600"
|
||||||
|
>
|
||||||
|
<Send size={12} className="rotate-45" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => archiveChat(e, chat.id)}
|
onClick={(e) => archiveChat(e, chat.id)}
|
||||||
className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600"
|
className="p-1 text-slate-400 hover:text-amber-600"
|
||||||
title="Archivar"
|
|
||||||
>
|
>
|
||||||
<Archive size={14} />
|
<Archive size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
// --- LISTA DE CHATS ARCHIVADOS ---
|
/* ... Resto del código de archivados (sin cambios) ... */
|
||||||
<div className="animate-in fade-in slide-in-from-left-2">
|
<div className="animate-in fade-in slide-in-from-left-2">
|
||||||
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
Archivados
|
Archivados
|
||||||
@@ -492,25 +569,17 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<Archive size={14} className="shrink-0 opacity-30" />
|
<Archive size={14} className="shrink-0 opacity-30" />
|
||||||
<span className="truncate pr-8">
|
<span className="truncate pr-8">
|
||||||
{chat.title ||
|
{chat.nombre ||
|
||||||
`Archivado ${chat.creado_en.split('T')[0]}`}
|
`Archivado ${chat.creado_en.split('T')[0]}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => unarchiveChat(e, chat.id)}
|
onClick={(e) => unarchiveChat(e, chat.id)}
|
||||||
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
|
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
|
||||||
title="Desarchivar"
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{archivedChats.length === 0 && (
|
|
||||||
<div className="px-2 py-4 text-center">
|
|
||||||
<p className="text-xs text-slate-400 italic">
|
|
||||||
No hay archivados
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -543,46 +612,98 @@ function RouteComponent() {
|
|||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
{messages.map((msg) => (
|
{!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"
|
||||||
|
/>
|
||||||
|
<h3 className="text-lg font-medium text-slate-900">
|
||||||
|
No hay un chat seleccionado
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Selecciona un chat del historial o crea uno nuevo para
|
||||||
|
empezar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{chatMessages.map((msg: any) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'ml-auto items-end' : 'items-start'}`}
|
className={`flex max-w-[85%] flex-col ${
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
|
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'rounded-tr-none bg-teal-600 text-white'
|
? 'ml-auto items-end'
|
||||||
: 'rounded-tl-none border bg-white text-slate-700'
|
: 'items-start'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Contenido de texto normal */}
|
<div
|
||||||
|
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'rounded-tr-none bg-teal-600 text-white'
|
||||||
|
: `rounded-tl-none border bg-white text-slate-700 ${
|
||||||
|
// --- LÓGICA DE REFUSAL ---
|
||||||
|
msg.isRefusal
|
||||||
|
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
|
||||||
|
: 'border-slate-100'
|
||||||
|
}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Icono opcional de advertencia si es refusal */}
|
||||||
|
{msg.isRefusal && (
|
||||||
|
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
|
||||||
|
<span>Aviso del Asistente</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{msg.content}
|
{msg.content}
|
||||||
|
|
||||||
{/* Si el mensaje tiene sugerencias (ImprovementCard) */}
|
{!msg.isRefusal &&
|
||||||
{msg.suggestions && msg.suggestions.length > 0 && (
|
msg.suggestions &&
|
||||||
|
msg.suggestions.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ImprovementCard
|
<ImprovementCard
|
||||||
suggestions={msg.suggestions}
|
suggestions={msg.suggestions}
|
||||||
planId={planId} // Del useParams()
|
planId={planId}
|
||||||
currentDatos={data?.datos} // De tu query usePlan(planId)
|
currentDatos={data?.datos}
|
||||||
onApply={(key, val) => {
|
activeChatId={activeChatId}
|
||||||
// Esto es opcional, si quieres hacer algo más en la UI del chat
|
onApplySuccess={(key) =>
|
||||||
console.log(
|
removeSelectedField(key)
|
||||||
'Evento onApply disparado desde el chat',
|
}
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isLoading && (
|
|
||||||
<div className="flex gap-2 p-4">
|
{optimisticMessage && (
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
|
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
|
{optimisticMessage}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSending && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
|
||||||
|
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
|
||||||
|
Esperando respuesta...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -613,25 +734,35 @@ function RouteComponent() {
|
|||||||
<div className="relative mx-auto max-w-4xl">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
<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 tracking-wider text-slate-500 uppercase">
|
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||||
Seleccionar campo para IA
|
Resultados para "{filterQuery}"
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto p-1">
|
<div className="max-h-64 overflow-y-auto p-1">
|
||||||
{availableFields.map((field) => (
|
{filteredFields.length > 0 ? (
|
||||||
|
filteredFields.map((field, index) => (
|
||||||
<button
|
<button
|
||||||
key={field.key}
|
key={field.key}
|
||||||
onClick={() => toggleField(field)}
|
onClick={() => toggleField(field)}
|
||||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-slate-700 group-hover:text-teal-700">
|
<span>{field.label}</span>
|
||||||
{field.label}
|
{index === 0 && (
|
||||||
|
<span className="font-mono text-[10px] opacity-50">
|
||||||
|
TAB
|
||||||
</span>
|
</span>
|
||||||
{selectedFields.find((f) => f.key === field.key) && (
|
|
||||||
<Check size={14} className="text-teal-600" />
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-3 text-center text-xs text-slate-400">
|
||||||
|
No hay coincidencias
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -663,6 +794,35 @@ function RouteComponent() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (showSuggestions) {
|
||||||
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||||
|
if (filteredFields.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleField(filteredFields[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setFilterQuery('')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Si el usuario borra y el input está vacío, eliminar el último campo
|
||||||
|
if (
|
||||||
|
e.key === 'Backspace' &&
|
||||||
|
input === '' &&
|
||||||
|
selectedFields.length > 0
|
||||||
|
) {
|
||||||
|
setSelectedFields((prev) => prev.slice(0, -1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !showSuggestions) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isSending) handleSend()
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
selectedFields.length > 0
|
selectedFields.length > 0
|
||||||
? 'Escribe instrucciones adicionales...'
|
? 'Escribe instrucciones adicionales...'
|
||||||
@@ -673,12 +833,16 @@ function RouteComponent() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
disabled={
|
disabled={
|
||||||
(!input.trim() && selectedFields.length === 0) || isLoading
|
isSending || (!input.trim() && selectedFields.length === 0)
|
||||||
}
|
}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
|
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
|
||||||
>
|
>
|
||||||
<Send size={16} className="text-white" />
|
{isSending ? (
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
) : (
|
||||||
|
<Send size={16} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1209,11 +1209,24 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
|
append_conversacion_asignatura: {
|
||||||
|
Args: { p_append: Json; p_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
|
append_conversacion_plan: {
|
||||||
|
Args: { p_append: Json; p_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
unaccent: { Args: { '': string }; Returns: string }
|
unaccent: { Args: { '': string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
|
estado_asignatura:
|
||||||
|
| 'borrador'
|
||||||
|
| 'revisada'
|
||||||
|
| 'aprobada'
|
||||||
|
| 'generando'
|
||||||
|
| 'fallida'
|
||||||
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
||||||
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
||||||
fuente_cambio: 'HUMANO' | 'IA'
|
fuente_cambio: 'HUMANO' | 'IA'
|
||||||
@@ -1387,7 +1400,13 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
|
estado_asignatura: [
|
||||||
|
'borrador',
|
||||||
|
'revisada',
|
||||||
|
'aprobada',
|
||||||
|
'generando',
|
||||||
|
'fallida',
|
||||||
|
],
|
||||||
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
||||||
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
||||||
fuente_cambio: ['HUMANO', 'IA'],
|
fuente_cambio: ['HUMANO', 'IA'],
|
||||||
|
|||||||
Reference in New Issue
Block a user