Compare commits
1 Commits
main
..
1eaf5cc4e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eaf5cc4e4 |
@@ -1,37 +0,0 @@
|
|||||||
name: Deploy to Azure Static Web Apps
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
env:
|
|
||||||
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
run: bunx --bun vite build
|
|
||||||
|
|
||||||
# No hace falta instalar el CLI globalmente, usamos bunx
|
|
||||||
- name: Deploy to Azure Static Web Apps
|
|
||||||
env:
|
|
||||||
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
|
|
||||||
run: |
|
|
||||||
bunx @azure/static-web-apps-cli deploy ./dist \
|
|
||||||
--env production \
|
|
||||||
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "acad-ia-2",
|
"name": "acad-ia-2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/react": "^0.3.2",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -41,7 +40,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
@@ -139,18 +138,6 @@
|
|||||||
|
|
||||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
||||||
|
|
||||||
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
|
|
||||||
|
|
||||||
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
|
|
||||||
|
|
||||||
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
|
|
||||||
|
|
||||||
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
|
|
||||||
|
|
||||||
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
|
|
||||||
|
|
||||||
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
|
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
@@ -263,8 +250,6 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
|
|
||||||
|
|
||||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
@@ -1327,7 +1312,7 @@
|
|||||||
|
|
||||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -17,7 +17,6 @@
|
|||||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/react": "^0.3.2",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"tailwindcss": "^4.0.6",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
"use-debounce": "^10.1.0",
|
"use-debounce": "^10.1.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 250 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,13 +7,6 @@ import type { AsignaturaDetail } from '@/data'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -21,9 +14,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { usePlanAsignaturas } from '@/data'
|
|
||||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||||
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
|
||||||
|
|
||||||
export interface BibliografiaEntry {
|
export interface BibliografiaEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -47,10 +38,6 @@ export interface AsignaturaResponse {
|
|||||||
datos: AsignaturaDatos
|
datos: AsignaturaDatos
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CriterioEvaluacionRow = {
|
type CriterioEvaluacionRow = {
|
||||||
criterio: string
|
criterio: string
|
||||||
porcentaje: number
|
porcentaje: number
|
||||||
@@ -72,12 +59,8 @@ export default function AsignaturaDetailPage() {
|
|||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
const { planId } = useParams({
|
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
})
|
|
||||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
|
||||||
usePlanAsignaturas(planId)
|
|
||||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
@@ -98,52 +81,16 @@ export default function AsignaturaDetailPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const asignaturaSeriada = useMemo(() => {
|
|
||||||
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
|
|
||||||
return null
|
|
||||||
return asignaturasApi.find(
|
|
||||||
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
|
|
||||||
)
|
|
||||||
}, [asignaturaApi, asignaturasApi])
|
|
||||||
const requisitosFormateados = useMemo(() => {
|
|
||||||
if (!asignaturaSeriada) return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'Pre-requisito',
|
|
||||||
code: asignaturaSeriada.codigo,
|
|
||||||
name: asignaturaSeriada.nombre,
|
|
||||||
id: asignaturaSeriada.id, // Guardamos el ID para el select
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, [asignaturaSeriada])
|
|
||||||
|
|
||||||
const handleUpdatePrerrequisito = (newId: string | null) => {
|
|
||||||
updateAsignatura.mutate({
|
|
||||||
asignaturaId,
|
|
||||||
patch: {
|
|
||||||
prerrequisito_asignatura_id: newId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/* ---------- sincronizar API ---------- */
|
/* ---------- sincronizar API ---------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
}, [asignaturaApi, requisitosFormateados])
|
}, [asignaturaApi])
|
||||||
|
|
||||||
return (
|
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
||||||
<DatosGenerales
|
|
||||||
pre={requisitosFormateados}
|
|
||||||
availableSubjects={asignaturasApi}
|
|
||||||
onPersistDato={handlePersistDatoGeneral}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DatosGenerales({
|
function DatosGenerales({
|
||||||
onPersistDato,
|
onPersistDato,
|
||||||
pre,
|
|
||||||
availableSubjects,
|
|
||||||
}: {
|
}: {
|
||||||
onPersistDato: (clave: string, value: string) => void
|
onPersistDato: (clave: string, value: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -180,6 +127,7 @@ function DatosGenerales({
|
|||||||
|
|
||||||
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
|
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
|
||||||
const raw = (data as any)?.criterios_de_evaluacion
|
const raw = (data as any)?.criterios_de_evaluacion
|
||||||
|
console.log(raw)
|
||||||
|
|
||||||
if (!Array.isArray(raw)) return []
|
if (!Array.isArray(raw)) return []
|
||||||
|
|
||||||
@@ -315,32 +263,24 @@ function DatosGenerales({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Tarjeta de Requisitos */}
|
{/* Tarjeta de Requisitos */}
|
||||||
<InfoCard
|
<InfoCard
|
||||||
asignaturaId={asignaturaId}
|
|
||||||
title="Requisitos y Seriación"
|
title="Requisitos y Seriación"
|
||||||
type="requirements"
|
type="requirements"
|
||||||
initialContent={pre}
|
initialContent={[
|
||||||
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
{
|
||||||
availableSubjects={
|
type: 'Pre-requisito',
|
||||||
availableSubjects?.filter(
|
code: 'PA-301',
|
||||||
(a) =>
|
name: 'Programación Avanzada',
|
||||||
a.id !== asignaturaId &&
|
},
|
||||||
a.numero_ciclo < data?.numero_ciclo &&
|
{
|
||||||
a.numero_ciclo,
|
type: 'Co-requisito',
|
||||||
) || []
|
code: 'MAT-201',
|
||||||
}
|
name: 'Matemáticas Discretas',
|
||||||
onPersist={({ value }) => {
|
},
|
||||||
updateAsignatura.mutate({
|
]}
|
||||||
asignaturaId,
|
|
||||||
patch: {
|
|
||||||
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tarjeta de Evaluación */}
|
{/* Tarjeta de Evaluación */}
|
||||||
<InfoCard
|
<InfoCard
|
||||||
asignaturaId={asignaturaId}
|
|
||||||
title="Sistema de Evaluación"
|
title="Sistema de Evaluación"
|
||||||
type="evaluation"
|
type="evaluation"
|
||||||
initialContent={criteriosEvaluacion}
|
initialContent={criteriosEvaluacion}
|
||||||
@@ -376,7 +316,6 @@ interface InfoCardProps {
|
|||||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
containerRef?: React.RefObject<HTMLDivElement | null>
|
||||||
forceEditToken?: number
|
forceEditToken?: number
|
||||||
highlightToken?: number
|
highlightToken?: number
|
||||||
availableSubjects?: any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
@@ -393,7 +332,6 @@ function InfoCard({
|
|||||||
containerRef,
|
containerRef,
|
||||||
forceEditToken,
|
forceEditToken,
|
||||||
highlightToken,
|
highlightToken,
|
||||||
availableSubjects,
|
|
||||||
}: InfoCardProps) {
|
}: InfoCardProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
@@ -453,6 +391,8 @@ function InfoCard({
|
|||||||
}, [highlightToken])
|
}, [highlightToken])
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
console.log('clave, valor:', clave, String(tempText ?? ''))
|
||||||
|
|
||||||
if (type === 'evaluation') {
|
if (type === 'evaluation') {
|
||||||
const cleaned: Array<CriterioEvaluacionRow> = []
|
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||||
for (const r of evalRows) {
|
for (const r of evalRows) {
|
||||||
@@ -482,23 +422,6 @@ function InfoCard({
|
|||||||
void onPersist?.({ type, clave, value: cleaned })
|
void onPersist?.({ type, clave, value: cleaned })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type === 'requirements') {
|
|
||||||
// Si tempText es un array y tiene elementos, tomamos el ID del primero
|
|
||||||
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
|
|
||||||
const prerequisiteId =
|
|
||||||
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
|
|
||||||
|
|
||||||
setData(tempText) // Actualiza la vista local
|
|
||||||
setIsEditing(false)
|
|
||||||
|
|
||||||
// Mandamos el ID específico a la base de datos
|
|
||||||
void onPersist?.({
|
|
||||||
type,
|
|
||||||
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
|
|
||||||
value: prerequisiteId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(tempText)
|
setData(tempText)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
@@ -509,21 +432,16 @@ function InfoCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (campoClave: string) => {
|
const handleIARequest = (campoClave: string) => {
|
||||||
let targetClave = campoClave
|
console.log(placeholder)
|
||||||
if (type === 'evaluation' && !targetClave) {
|
|
||||||
targetClave = 'criterios_de_evaluacion'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetClave === 'contenido') {
|
|
||||||
targetClave = 'contenido_tematico'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Añadimos un timestamp a la state para forzar que la navegación
|
||||||
|
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
||||||
navigate({
|
navigate({
|
||||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||||
params: { planId, asignaturaId: asignaturaId! },
|
params: { planId, asignaturaId: asignaturaId! },
|
||||||
state: {
|
state: {
|
||||||
activeTab: 'ia',
|
activeTab: 'ia',
|
||||||
prefillCampo: targetClave,
|
prefillCampo: campoClave,
|
||||||
prefillContenido: data,
|
prefillContenido: data,
|
||||||
_ts: Date.now(),
|
_ts: Date.now(),
|
||||||
} as any,
|
} as any,
|
||||||
@@ -578,21 +496,19 @@ function InfoCard({
|
|||||||
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{type !== 'requirements' && (
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
onClick={() => clave && handleIARequest(clave)}
|
||||||
onClick={() => handleIARequest(clave)}
|
>
|
||||||
>
|
<Sparkles className="h-4 w-4" />
|
||||||
<Sparkles className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Mejorar con IA</TooltipContent>
|
||||||
<TooltipContent>Mejorar con IA</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -625,69 +541,7 @@ function InfoCard({
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Condicionales de edición según el tipo */}
|
{type === 'evaluation' ? (
|
||||||
{type === 'requirements' ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-xs font-medium text-slate-500">
|
|
||||||
Materia de Seriación
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
Array.isArray(tempText) && tempText.length > 0
|
|
||||||
? tempText[0].id
|
|
||||||
: 'none'
|
|
||||||
}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
const selected = availableSubjects?.find(
|
|
||||||
(s) => s.id === val,
|
|
||||||
)
|
|
||||||
if (val === 'none' || !selected) {
|
|
||||||
setTempText([])
|
|
||||||
} else {
|
|
||||||
setTempText([
|
|
||||||
{
|
|
||||||
id: selected.id,
|
|
||||||
type: 'Pre-requisito',
|
|
||||||
code: selected.codigo,
|
|
||||||
name: selected.nombre,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<div className="flex-1 truncate text-left">
|
|
||||||
<SelectValue placeholder="Selecciona una materia">
|
|
||||||
{Array.isArray(tempText) && tempText.length > 0
|
|
||||||
? `${tempText[0].code} - ${tempText[0].name}`
|
|
||||||
: undefined}
|
|
||||||
</SelectValue>
|
|
||||||
</div>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">
|
|
||||||
Ninguna (Sin seriación)
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{availableSubjects?.map((asig) => (
|
|
||||||
<SelectItem
|
|
||||||
key={asig.id}
|
|
||||||
value={asig.id}
|
|
||||||
className="max-w-[300px] sm:max-w-[500px]"
|
|
||||||
>
|
|
||||||
<span className="text-primary font-bold">
|
|
||||||
[C{asig.numero_ciclo}]
|
|
||||||
</span>{' '}
|
|
||||||
<span className="inline-block truncate">
|
|
||||||
{asig.codigo} - {asig.nombre}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
) : type === 'evaluation' ? (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{evalRows.map((row) => (
|
{evalRows.map((row) => (
|
||||||
@@ -709,36 +563,85 @@ function InfoCard({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={row.porcentaje}
|
value={row.porcentaje}
|
||||||
placeholder="%"
|
placeholder="%"
|
||||||
type="number"
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
|
// Solo permitir '' o dígitos
|
||||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||||
|
|
||||||
|
if (raw === '') {
|
||||||
|
setEvalRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === row.id
|
||||||
|
? {
|
||||||
|
id: r.id,
|
||||||
|
criterio: r.criterio,
|
||||||
|
porcentaje: '',
|
||||||
|
}
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n)) return
|
||||||
|
const porcentaje = Math.trunc(n)
|
||||||
|
if (porcentaje < 1 || porcentaje > 100) return
|
||||||
|
|
||||||
|
// No permitir suma > 100
|
||||||
setEvalRows((prev) => {
|
setEvalRows((prev) => {
|
||||||
const next = prev.map((r) =>
|
const next = prev.map((r) =>
|
||||||
r.id === row.id ? { ...r, porcentaje: raw } : r,
|
r.id === row.id
|
||||||
)
|
? {
|
||||||
const total = next.reduce(
|
id: r.id,
|
||||||
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
criterio: r.criterio,
|
||||||
0,
|
porcentaje: raw,
|
||||||
|
}
|
||||||
|
: r,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const total = next.reduce((acc, r) => {
|
||||||
|
const v = String(r.porcentaje).trim()
|
||||||
|
if (!v) return acc
|
||||||
|
const nn = Number(v)
|
||||||
|
if (!Number.isFinite(nn)) return acc
|
||||||
|
const vv = Math.trunc(nn)
|
||||||
|
if (vv < 1 || vv > 100) return acc
|
||||||
|
return acc + vv
|
||||||
|
}, 0)
|
||||||
|
|
||||||
return total > 100 ? prev : next
|
return total > 100 ? prev : next
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-sm text-slate-600">%</div>
|
|
||||||
|
<div
|
||||||
|
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setEvalRows((prev) =>
|
setEvalRows((prev) =>
|
||||||
prev.filter((r) => r.id !== row.id),
|
prev.filter((r) => r.id !== row.id),
|
||||||
)
|
)
|
||||||
}
|
}}
|
||||||
|
aria-label="Quitar renglón"
|
||||||
|
title="Quitar"
|
||||||
>
|
>
|
||||||
<Minus className="h-4 w-4" />
|
<Minus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -748,15 +651,22 @@ function InfoCard({
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
className={
|
||||||
|
'text-sm ' +
|
||||||
|
(evaluationTotal === 100
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-destructive font-semibold')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Total: {evaluationTotal}/100
|
Total: {evaluationTotal}/100
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-emerald-700 hover:bg-emerald-50"
|
className="text-emerald-700 hover:bg-emerald-50"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
// Agregar una fila vacía (siempre permitido)
|
||||||
setEvalRows((prev) => [
|
setEvalRows((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -765,7 +675,7 @@ function InfoCard({
|
|||||||
porcentaje: '',
|
porcentaje: '',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||||
</Button>
|
</Button>
|
||||||
@@ -779,15 +689,28 @@ function InfoCard({
|
|||||||
className="min-h-30 text-sm leading-relaxed"
|
className="min-h-30 text-sm leading-relaxed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Botones de acción comunes */}
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
// Lógica de reset si es necesario...
|
if (type === 'evaluation') {
|
||||||
|
const raw = Array.isArray(data) ? data : []
|
||||||
|
setEvalRows(
|
||||||
|
raw.map((r: CriterioEvaluacionRow) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
criterio:
|
||||||
|
typeof r.criterio === 'string' ? r.criterio : '',
|
||||||
|
porcentaje:
|
||||||
|
typeof r.porcentaje === 'number'
|
||||||
|
? String(Math.trunc(r.porcentaje))
|
||||||
|
: typeof r.porcentaje === 'string'
|
||||||
|
? String(Math.trunc(Number(r.porcentaje)))
|
||||||
|
: '',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
@@ -803,7 +726,6 @@ function InfoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Modo Visualización */
|
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
{type === 'text' &&
|
{type === 'text' &&
|
||||||
(data ? (
|
(data ? (
|
||||||
@@ -812,7 +734,9 @@ function InfoCard({
|
|||||||
<p className="text-slate-400 italic">Sin información.</p>
|
<p className="text-slate-400 italic">Sin información.</p>
|
||||||
))}
|
))}
|
||||||
{type === 'requirements' && <RequirementsView items={data} />}
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
{type === 'evaluation' && (
|
||||||
|
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -867,3 +791,80 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContenidoTematicoToPlainText(value: unknown): string {
|
||||||
|
if (!Array.isArray(value)) return ''
|
||||||
|
|
||||||
|
const blocks: Array<string> = []
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
|
||||||
|
const unidad =
|
||||||
|
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
||||||
|
? item.unidad
|
||||||
|
: undefined
|
||||||
|
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
||||||
|
|
||||||
|
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
||||||
|
if (!header) continue
|
||||||
|
|
||||||
|
const lines: Array<string> = [header]
|
||||||
|
|
||||||
|
const temas = Array.isArray(item.temas) ? item.temas : []
|
||||||
|
temas.forEach((tema, idx) => {
|
||||||
|
const temaNombre =
|
||||||
|
typeof tema === 'string'
|
||||||
|
? tema
|
||||||
|
: isRecord(tema) && typeof tema.nombre === 'string'
|
||||||
|
? tema.nombre
|
||||||
|
: ''
|
||||||
|
if (!temaNombre) return
|
||||||
|
|
||||||
|
if (unidad != null) {
|
||||||
|
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
||||||
|
} else {
|
||||||
|
lines.push(`${idx + 1}. ${temaNombre}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
blocks.push(lines.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.join('\n\n').trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
||||||
|
if (!Array.isArray(value)) return ''
|
||||||
|
|
||||||
|
const lines: Array<string> = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isRecord(item)) continue
|
||||||
|
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
||||||
|
const valueNum =
|
||||||
|
typeof item.porcentaje === 'number'
|
||||||
|
? item.porcentaje
|
||||||
|
: typeof item.porcentaje === 'string'
|
||||||
|
? Number(item.porcentaje)
|
||||||
|
: NaN
|
||||||
|
|
||||||
|
if (!label) continue
|
||||||
|
if (!Number.isFinite(valueNum)) continue
|
||||||
|
|
||||||
|
const v = Math.trunc(valueNum)
|
||||||
|
if (v < 1 || v > 100) continue
|
||||||
|
|
||||||
|
lines.push(`${label}: ${v}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||||
|
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||||
|
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ export function BibliographyItem() {
|
|||||||
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in space-y-8 pb-8 duration-500">
|
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
||||||
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
Bibliografía
|
Bibliografía
|
||||||
@@ -169,7 +169,7 @@ export function BibliographyItem() {
|
|||||||
resetScroll: false,
|
resetScroll: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="shadow-md"
|
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
|
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { DragDropProvider } from '@dnd-kit/react'
|
|
||||||
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
|
|
||||||
import { useParams } from '@tanstack/react-router'
|
import { useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -13,7 +11,7 @@ import {
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
||||||
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
|
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -52,95 +50,6 @@ export interface UnidadTematica {
|
|||||||
temas: Array<Tema>
|
temas: Array<Tema>
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClientId(prefix: string) {
|
|
||||||
try {
|
|
||||||
const c = (globalThis as any).crypto
|
|
||||||
if (c && typeof c.randomUUID === 'function')
|
|
||||||
return `${prefix}-${c.randomUUID()}`
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayMove<T>(array: Array<T>, fromIndex: number, toIndex: number) {
|
|
||||||
const next = array.slice()
|
|
||||||
const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex
|
|
||||||
if (startIndex < 0 || startIndex >= next.length) return next
|
|
||||||
const endIndex = toIndex < 0 ? next.length + toIndex : toIndex
|
|
||||||
const [item] = next.splice(startIndex, 1)
|
|
||||||
next.splice(endIndex, 0, item)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function renumberUnidades(unidades: Array<UnidadTematica>) {
|
|
||||||
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function InsertUnidadOverlay({
|
|
||||||
onInsert,
|
|
||||||
position,
|
|
||||||
}: {
|
|
||||||
onInsert: () => void
|
|
||||||
position: 'top' | 'bottom'
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
|
|
||||||
// Match the `space-y-4` gap so the hover target is *between* units.
|
|
||||||
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="bg-background/95 border-border/60 hover:bg-background cursor-pointer opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onInsert()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableUnidad({
|
|
||||||
id,
|
|
||||||
index,
|
|
||||||
registerContainer,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
id: string
|
|
||||||
index: number
|
|
||||||
registerContainer: (el: HTMLDivElement | null) => void
|
|
||||||
children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode
|
|
||||||
}) {
|
|
||||||
const { ref, handleRef, isDragSource, isDropTarget } = useSortable({
|
|
||||||
id,
|
|
||||||
index,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
ref(el)
|
|
||||||
registerContainer(el)
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'group relative',
|
|
||||||
isDragSource && 'opacity-80',
|
|
||||||
isDropTarget && 'ring-primary/20 ring-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children({ handleRef })}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
@@ -191,18 +100,20 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
|
|||||||
if (Array.isArray(value.temas)) {
|
if (Array.isArray(value.temas)) {
|
||||||
temas = value.temas
|
temas = value.temas
|
||||||
.map(mapTemaValue)
|
.map(mapTemaValue)
|
||||||
.filter((x): x is ContenidoTemaApi => x !== null)
|
.filter((t): t is ContenidoTemaApi => t !== null)
|
||||||
|
} else if (typeof value.temas === 'string' && value.temas.trim()) {
|
||||||
|
temas = value.temas
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { unidad, titulo, temas }
|
||||||
...value,
|
|
||||||
unidad,
|
|
||||||
titulo,
|
|
||||||
temas,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
||||||
|
if (value == null) return []
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
try {
|
try {
|
||||||
return mapContenidoTematicoFromDb(JSON.parse(value))
|
return mapContenidoTematicoFromDb(JSON.parse(value))
|
||||||
@@ -281,16 +192,7 @@ export function ContenidoTematico() {
|
|||||||
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
||||||
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
||||||
|
|
||||||
const didInitExpandedUnitsRef = useRef(false)
|
|
||||||
|
|
||||||
const unidadesRef = useRef<Array<UnidadTematica>>([])
|
|
||||||
useEffect(() => {
|
|
||||||
unidadesRef.current = unidades
|
|
||||||
}, [unidades])
|
|
||||||
|
|
||||||
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
||||||
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
|
|
||||||
didInitExpandedUnitsRef.current = true
|
|
||||||
const payload = serializeUnidadesToApi(nextUnidades)
|
const payload = serializeUnidadesToApi(nextUnidades)
|
||||||
await updateContenido.mutateAsync({
|
await updateContenido.mutateAsync({
|
||||||
subjectId: asignaturaId,
|
subjectId: asignaturaId,
|
||||||
@@ -344,17 +246,10 @@ export function ContenidoTematico() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseHorasEstimadas = (raw: string): number => {
|
|
||||||
const normalized = raw.trim().replace(',', '.')
|
|
||||||
const parsed = Number.parseFloat(normalized)
|
|
||||||
if (!Number.isFinite(parsed)) return 0
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitEditTema = () => {
|
const commitEditTema = () => {
|
||||||
if (!editingTema) return
|
if (!editingTema) return
|
||||||
const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
|
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
|
||||||
|
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
|
||||||
|
|
||||||
const next = unidades.map((u) => {
|
const next = unidades.map((u) => {
|
||||||
if (u.id !== editingTema.unitId) return u
|
if (u.id !== editingTema.unitId) return u
|
||||||
@@ -408,110 +303,28 @@ export function ContenidoTematico() {
|
|||||||
data ? data.contenido_tematico : undefined,
|
data ? data.contenido_tematico : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
|
const transformed = contenido.map((u, idx) => ({
|
||||||
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
|
id: `u-${u.unidad || idx + 1}`,
|
||||||
const currentPayload = JSON.stringify(
|
numero: u.unidad || idx + 1,
|
||||||
serializeUnidadesToApi(unidadesRef.current),
|
nombre: u.titulo || 'Sin título',
|
||||||
)
|
temas: Array.isArray(u.temas)
|
||||||
|
? u.temas.map((t: any, tidx: number) => ({
|
||||||
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
|
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
||||||
const incomingPayload = JSON.stringify(
|
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
|
||||||
contenido.map((u, idx) => ({
|
horasEstimadas: t?.horasEstimadas || 0,
|
||||||
unidad: u.unidad || idx + 1,
|
}))
|
||||||
titulo: u.titulo || 'Sin título',
|
: [],
|
||||||
temas: Array.isArray(u.temas)
|
}))
|
||||||
? u.temas.map((t) => {
|
|
||||||
if (typeof t === 'string') {
|
|
||||||
return {
|
|
||||||
nombre: t,
|
|
||||||
horasEstimadas: 0,
|
|
||||||
descripcion: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nombre: t.nombre || 'Tema',
|
|
||||||
horasEstimadas: t.horasEstimadas ?? 0,
|
|
||||||
descripcion: t.descripcion,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Si los datos son idénticos, abortamos el useEffect.
|
|
||||||
// ¡Nuestros IDs locales se salvan y no hay parpadeos!
|
|
||||||
if (currentPayload === incomingPayload && unidadesRef.current.length > 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado.
|
|
||||||
// Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE.
|
|
||||||
const prevUnidades = [...unidadesRef.current]
|
|
||||||
|
|
||||||
const transformed = contenido.map((u, idx) => {
|
|
||||||
const dbTitulo = u.titulo || 'Sin título'
|
|
||||||
|
|
||||||
// Buscamos si ya existe una unidad con este mismo título
|
|
||||||
const existingUnitIndex = prevUnidades.findIndex(
|
|
||||||
(prev) => prev.nombre === dbTitulo,
|
|
||||||
)
|
|
||||||
let unidadId
|
|
||||||
let existingUnit = null
|
|
||||||
|
|
||||||
if (existingUnitIndex !== -1) {
|
|
||||||
existingUnit = prevUnidades[existingUnitIndex]
|
|
||||||
unidadId = existingUnit.id
|
|
||||||
prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo
|
|
||||||
} else {
|
|
||||||
unidadId = createClientId(`u-${u.unidad || idx + 1}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: unidadId,
|
|
||||||
numero: u.unidad || idx + 1,
|
|
||||||
nombre: dbTitulo,
|
|
||||||
temas: Array.isArray(u.temas)
|
|
||||||
? u.temas.map((t: any, tidx: number) => {
|
|
||||||
const dbTemaNombre =
|
|
||||||
typeof t === 'string' ? t : t?.nombre || 'Tema'
|
|
||||||
|
|
||||||
// Reciclamos subtemas por nombre también
|
|
||||||
const existingTema = existingUnit?.temas.find(
|
|
||||||
(prevT) => prevT.nombre === dbTemaNombre,
|
|
||||||
)
|
|
||||||
const temaId = existingTema
|
|
||||||
? existingTema.id
|
|
||||||
: createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: temaId,
|
|
||||||
nombre: dbTemaNombre,
|
|
||||||
horasEstimadas:
|
|
||||||
coerceNumber(
|
|
||||||
typeof t === 'string' ? undefined : t?.horasEstimadas,
|
|
||||||
) ?? 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setUnidades(transformed)
|
setUnidades(transformed)
|
||||||
|
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
||||||
setExpandedUnits((prev) => {
|
setExpandedUnits((prev) => {
|
||||||
const validIds = new Set(transformed.map((u) => u.id))
|
const validIds = new Set(transformed.map((u) => u.id))
|
||||||
const filtered = new Set(
|
const filtered = new Set(
|
||||||
Array.from(prev).filter((id) => validIds.has(id)),
|
Array.from(prev).filter((id) => validIds.has(id)),
|
||||||
)
|
)
|
||||||
|
if (filtered.size > 0) return filtered
|
||||||
// Expandir la primera unidad solo una vez al llegar a la ruta.
|
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
||||||
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
|
|
||||||
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
|
|
||||||
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
})
|
})
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
@@ -540,7 +353,7 @@ export function ContenidoTematico() {
|
|||||||
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
||||||
const totalHoras = unidades.reduce(
|
const totalHoras = unidades.reduce(
|
||||||
(acc, u) =>
|
(acc, u) =>
|
||||||
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0),
|
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -551,22 +364,16 @@ export function ContenidoTematico() {
|
|||||||
setExpandedUnits(newExpanded)
|
setExpandedUnits(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertUnidadAt = (insertIndex: number) => {
|
const addUnidad = () => {
|
||||||
const newId = createClientId('u')
|
const newNumero = unidades.length + 1
|
||||||
|
const newId = `u-${newNumero}`
|
||||||
const newUnidad: UnidadTematica = {
|
const newUnidad: UnidadTematica = {
|
||||||
id: newId,
|
id: newId,
|
||||||
nombre: 'Nueva Unidad',
|
nombre: 'Nueva Unidad',
|
||||||
numero: 0,
|
numero: newNumero,
|
||||||
temas: [],
|
temas: [],
|
||||||
}
|
}
|
||||||
|
const next = [...unidades, newUnidad]
|
||||||
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
|
|
||||||
const next = renumberUnidades([
|
|
||||||
...unidades.slice(0, clampedIndex),
|
|
||||||
newUnidad,
|
|
||||||
...unidades.slice(clampedIndex),
|
|
||||||
])
|
|
||||||
|
|
||||||
setUnidades(next)
|
setUnidades(next)
|
||||||
setExpandedUnits((prev) => {
|
setExpandedUnits((prev) => {
|
||||||
const n = new Set(prev)
|
const n = new Set(prev)
|
||||||
@@ -575,40 +382,10 @@ export function ContenidoTematico() {
|
|||||||
})
|
})
|
||||||
setPendingScrollUnitId(newId)
|
setPendingScrollUnitId(newId)
|
||||||
|
|
||||||
|
// Abrir edición del título inmediatamente
|
||||||
setEditingUnit(newId)
|
setEditingUnit(newId)
|
||||||
setUnitDraftNombre(newUnidad.nombre)
|
setUnitDraftNombre(newUnidad.nombre)
|
||||||
setUnitOriginalNombre(newUnidad.nombre)
|
setUnitOriginalNombre(newUnidad.nombre)
|
||||||
|
|
||||||
void persistUnidades(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReorderEnd = (event: any) => {
|
|
||||||
if (event?.canceled) return
|
|
||||||
|
|
||||||
const source = event?.operation?.source
|
|
||||||
if (!source) return
|
|
||||||
|
|
||||||
// Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden
|
|
||||||
if (!isSortable(source)) return
|
|
||||||
|
|
||||||
// Extraemos las posiciones exactas calculadas por dnd-kit
|
|
||||||
const { initialIndex, index } = source.sortable
|
|
||||||
|
|
||||||
// Si lo soltó en la misma posición de la que salió, cancelamos
|
|
||||||
if (initialIndex === index) return
|
|
||||||
|
|
||||||
setUnidades((prev) => {
|
|
||||||
// Hacemos el movimiento usando los índices directos
|
|
||||||
const moved = arrayMove(prev, initialIndex, index)
|
|
||||||
const next = renumberUnidades(moved)
|
|
||||||
|
|
||||||
// Disparamos la persistencia hacia Supabase
|
|
||||||
void persistUnidades(next).catch((err) => {
|
|
||||||
console.error('No se pudo guardar el orden de unidades', err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lógica de Temas ---
|
// --- Lógica de Temas ---
|
||||||
@@ -662,7 +439,7 @@ export function ContenidoTematico() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in space-y-6 pb-8 duration-500">
|
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 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">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
@@ -674,182 +451,158 @@ export function ContenidoTematico() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragDropProvider onDragEnd={handleReorderEnd}>
|
<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
{unidades.map((unidad) => (
|
||||||
{unidades.map((unidad, index) => (
|
<div
|
||||||
<SortableUnidad
|
key={unidad.id}
|
||||||
key={unidad.id}
|
ref={(el) => {
|
||||||
id={unidad.id}
|
if (el) unitContainerRefs.current.set(unidad.id, el)
|
||||||
index={index}
|
else unitContainerRefs.current.delete(unidad.id)
|
||||||
registerContainer={(el) => {
|
}}
|
||||||
if (el) unitContainerRefs.current.set(unidad.id, el)
|
>
|
||||||
else unitContainerRefs.current.delete(unidad.id)
|
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
}}
|
<Collapsible
|
||||||
>
|
open={expandedUnits.has(unidad.id)}
|
||||||
{({ handleRef }) => (
|
onOpenChange={() => toggleUnit(unidad.id)}
|
||||||
<>
|
>
|
||||||
{index === 0 && (
|
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
||||||
<InsertUnidadOverlay
|
<div className="flex items-center gap-3">
|
||||||
position="top"
|
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
||||||
onInsert={() => insertUnidadAt(index)}
|
<CollapsibleTrigger asChild>
|
||||||
/>
|
<Button variant="ghost" size="sm" className="h-auto p-0">
|
||||||
)}
|
{expandedUnits.has(unidad.id) ? (
|
||||||
<InsertUnidadOverlay
|
<ChevronDown className="h-4 w-4" />
|
||||||
position="bottom"
|
) : (
|
||||||
onInsert={() => insertUnidadAt(index + 1)}
|
<ChevronRight className="h-4 w-4" />
|
||||||
/>
|
)}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Badge className="bg-blue-600 font-mono">
|
||||||
|
Unidad {unidad.numero}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
{editingUnit === unidad.id ? (
|
||||||
<Collapsible
|
<Input
|
||||||
open={expandedUnits.has(unidad.id)}
|
ref={unitTitleInputRef}
|
||||||
onOpenChange={() => toggleUnit(unidad.id)}
|
value={unitDraftNombre}
|
||||||
>
|
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
||||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
onBlur={() => {
|
||||||
<div className="flex items-center gap-3">
|
if (cancelNextBlurRef.current) {
|
||||||
<span
|
cancelNextBlurRef.current = false
|
||||||
ref={handleRef as any}
|
return
|
||||||
className="inline-flex cursor-grab touch-none items-center text-slate-300"
|
}
|
||||||
aria-label="Reordenar unidad"
|
commitEditUnit()
|
||||||
>
|
}}
|
||||||
<GripVertical className="h-4 w-4" />
|
onKeyDown={(e) => {
|
||||||
</span>
|
if (e.key === 'Enter') {
|
||||||
<CollapsibleTrigger asChild>
|
e.preventDefault()
|
||||||
<Button
|
e.currentTarget.blur()
|
||||||
variant="ghost"
|
return
|
||||||
size="sm"
|
}
|
||||||
className="h-auto cursor-pointer p-0"
|
if (e.key === 'Escape') {
|
||||||
>
|
e.preventDefault()
|
||||||
{expandedUnits.has(unidad.id) ? (
|
cancelNextBlurRef.current = true
|
||||||
<ChevronDown className="h-4 w-4" />
|
cancelEditUnit()
|
||||||
) : (
|
e.currentTarget.blur()
|
||||||
<ChevronRight className="h-4 w-4" />
|
}
|
||||||
)}
|
}}
|
||||||
</Button>
|
className="h-8 max-w-md bg-white"
|
||||||
</CollapsibleTrigger>
|
/>
|
||||||
<Badge className="bg-blue-600 font-mono">
|
) : (
|
||||||
Unidad {unidad.numero}
|
<CardTitle
|
||||||
</Badge>
|
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
||||||
|
onClick={() => beginEditUnit(unidad.id)}
|
||||||
|
>
|
||||||
|
{unidad.nombre}
|
||||||
|
</CardTitle>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingUnit === unidad.id ? (
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<Input
|
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
|
||||||
ref={unitTitleInputRef}
|
<Clock className="h-3 w-3" />{' '}
|
||||||
value={unitDraftNombre}
|
{unidad.temas.reduce(
|
||||||
onChange={(e) =>
|
(sum, t) => sum + (t.horasEstimadas || 0),
|
||||||
setUnitDraftNombre(e.target.value)
|
0,
|
||||||
}
|
)}
|
||||||
onBlur={() => {
|
h
|
||||||
if (cancelNextBlurRef.current) {
|
</span>
|
||||||
cancelNextBlurRef.current = false
|
<Button
|
||||||
return
|
variant="ghost"
|
||||||
}
|
size="icon"
|
||||||
commitEditUnit()
|
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
||||||
}}
|
onClick={() =>
|
||||||
onKeyDown={(e) => {
|
setDeleteDialog({ type: 'unidad', id: unidad.id })
|
||||||
if (e.key === 'Enter') {
|
}
|
||||||
e.preventDefault()
|
>
|
||||||
e.currentTarget.blur()
|
<Trash2 className="h-4 w-4" />
|
||||||
return
|
</Button>
|
||||||
}
|
</div>
|
||||||
if (e.key === 'Escape') {
|
</div>
|
||||||
e.preventDefault()
|
</CardHeader>
|
||||||
cancelNextBlurRef.current = true
|
<CollapsibleContent>
|
||||||
cancelEditUnit()
|
<CardContent className="bg-white pt-4">
|
||||||
e.currentTarget.blur()
|
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
||||||
}
|
{unidad.temas.map((tema, idx) => (
|
||||||
}}
|
<TemaRow
|
||||||
className="h-8 max-w-md bg-white"
|
key={tema.id}
|
||||||
/>
|
tema={tema}
|
||||||
) : (
|
index={idx + 1}
|
||||||
<CardTitle
|
isEditing={
|
||||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
!!editingTema &&
|
||||||
onClick={() => beginEditUnit(unidad.id)}
|
editingTema.unitId === unidad.id &&
|
||||||
>
|
editingTema.temaId === tema.id
|
||||||
{unidad.nombre}
|
}
|
||||||
</CardTitle>
|
draftNombre={temaDraftNombre}
|
||||||
)}
|
draftHoras={temaDraftHoras}
|
||||||
|
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
||||||
|
onDraftNombreChange={setTemaDraftNombre}
|
||||||
|
onDraftHorasChange={setTemaDraftHoras}
|
||||||
|
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
||||||
|
onEditorKeyDownCapture={
|
||||||
|
handleTemaEditorKeyDownCapture
|
||||||
|
}
|
||||||
|
onNombreInputRef={(el) => {
|
||||||
|
temaNombreInputElRef.current = el
|
||||||
|
}}
|
||||||
|
onDelete={() =>
|
||||||
|
setDeleteDialog({
|
||||||
|
type: 'tema',
|
||||||
|
id: tema.id,
|
||||||
|
parentId: unidad.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
onClick={() => addTema(unidad.id)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="flex justify-center pt-2">
|
||||||
<span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
|
<Button
|
||||||
<Clock className="h-3 w-3" />{' '}
|
variant="outline"
|
||||||
{unidad.temas.reduce(
|
className="gap-2"
|
||||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
onClick={(e) => {
|
||||||
0,
|
// Evita que Enter vuelva a disparar el click sobre el botón.
|
||||||
)}
|
e.currentTarget.blur()
|
||||||
h
|
addUnidad()
|
||||||
</span>
|
}}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Plus className="h-4 w-4" /> Nueva unidad
|
||||||
size="icon"
|
</Button>
|
||||||
className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
|
</div>
|
||||||
onClick={() =>
|
|
||||||
setDeleteDialog({
|
|
||||||
type: 'unidad',
|
|
||||||
id: unidad.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="bg-white pt-4">
|
|
||||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
|
||||||
{unidad.temas.map((tema, idx) => (
|
|
||||||
<TemaRow
|
|
||||||
key={tema.id}
|
|
||||||
tema={tema}
|
|
||||||
index={idx + 1}
|
|
||||||
isEditing={
|
|
||||||
!!editingTema &&
|
|
||||||
editingTema.unitId === unidad.id &&
|
|
||||||
editingTema.temaId === tema.id
|
|
||||||
}
|
|
||||||
draftNombre={temaDraftNombre}
|
|
||||||
draftHoras={temaDraftHoras}
|
|
||||||
onBeginEdit={() =>
|
|
||||||
beginEditTema(unidad.id, tema.id)
|
|
||||||
}
|
|
||||||
onDraftNombreChange={setTemaDraftNombre}
|
|
||||||
onDraftHorasChange={setTemaDraftHoras}
|
|
||||||
onEditorBlurCapture={
|
|
||||||
handleTemaEditorBlurCapture
|
|
||||||
}
|
|
||||||
onEditorKeyDownCapture={
|
|
||||||
handleTemaEditorKeyDownCapture
|
|
||||||
}
|
|
||||||
onNombreInputRef={(el) => {
|
|
||||||
temaNombreInputElRef.current = el
|
|
||||||
}}
|
|
||||||
onDelete={() =>
|
|
||||||
setDeleteDialog({
|
|
||||||
type: 'tema',
|
|
||||||
id: tema.id,
|
|
||||||
parentId: unidad.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
|
||||||
onClick={() => addTema(unidad.id)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SortableUnidad>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DragDropProvider>
|
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
dialog={deleteDialog}
|
dialog={deleteDialog}
|
||||||
@@ -914,9 +667,6 @@ function TemaRow({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={draftHoras}
|
value={draftHoras}
|
||||||
min={0}
|
|
||||||
max={200}
|
|
||||||
step={0.5}
|
|
||||||
onChange={(e) => onDraftHorasChange(e.target.value)}
|
onChange={(e) => onDraftHorasChange(e.target.value)}
|
||||||
className="h-8 w-16 bg-white"
|
className="h-8 w-16 bg-white"
|
||||||
/>
|
/>
|
||||||
@@ -925,7 +675,7 @@ function TemaRow({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex flex-1 cursor-pointer items-center gap-3 text-left"
|
className="flex flex-1 items-center gap-3 text-left"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onBeginEdit()
|
onBeginEdit()
|
||||||
@@ -940,7 +690,7 @@ function TemaRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600"
|
className="h-7 w-7 text-slate-400 hover:text-blue-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onBeginEdit()
|
onBeginEdit()
|
||||||
@@ -951,7 +701,7 @@ function TemaRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500"
|
className="h-7 w-7 text-slate-400 hover:text-red-500"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDelete()
|
onDelete()
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import { Card } from '@/components/ui/card'
|
|||||||
interface DocumentoSEPTabProps {
|
interface DocumentoSEPTabProps {
|
||||||
pdfUrl: string | null
|
pdfUrl: string | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
onDownloadPdf: () => void
|
onDownload: () => void
|
||||||
onDownloadWord: () => void
|
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
@@ -27,8 +26,7 @@ interface DocumentoSEPTabProps {
|
|||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
pdfUrl,
|
pdfUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
onDownloadPdf,
|
onDownload,
|
||||||
onDownloadWord,
|
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
isRegenerating,
|
isRegenerating,
|
||||||
}: DocumentoSEPTabProps) {
|
}: DocumentoSEPTabProps) {
|
||||||
@@ -54,23 +52,25 @@ export function DocumentoSEPTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{pdfUrl && !isLoading && (
|
||||||
|
<Button variant="outline" onClick={onDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Descargar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={showConfirmDialog}
|
open={showConfirmDialog}
|
||||||
onOpenChange={setShowConfirmDialog}
|
onOpenChange={setShowConfirmDialog}
|
||||||
>
|
>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button disabled={isRegenerating}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
disabled={isRegenerating}
|
|
||||||
>
|
|
||||||
{isRegenerating ? (
|
{isRegenerating ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
@@ -91,27 +91,11 @@ export function DocumentoSEPTab({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{pdfUrl && !isLoading && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" className="gap-2" onClick={onDownloadWord}>
|
|
||||||
<Download className="h-4 w-4" /> Descargar Word
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={onDownloadPdf}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Descargar PDF
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PDF Preview */}
|
{/* PDF Preview */}
|
||||||
<Card className="h-200 overflow-hidden">
|
<Card className="h-[800px] overflow-hidden">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<Loader2 className="h-10 w-10 animate-spin" />
|
<Loader2 className="h-10 w-10 animate-spin" />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Calendar,
|
Calendar,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
|
||||||
@@ -248,16 +249,7 @@ export function HistorialTab() {
|
|||||||
`bg-current ${config.color}`,
|
`bg-current ${config.color}`,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card className="card-interactive">
|
||||||
className="border-border card-interactive hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
|
|
||||||
onClick={() => openCompareModal(cambio)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ')
|
|
||||||
openCompareModal(cambio)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
@@ -273,7 +265,16 @@ export function HistorialTab() {
|
|||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{cambio.descripcion}
|
{cambio.descripcion}
|
||||||
</p>
|
</p>
|
||||||
|
{/* BOTÓN PARA VER CAMBIOS */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||||
|
onClick={() => openCompareModal(cambio)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Ver cambios
|
||||||
|
</Button>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{format(cambio.fecha, 'HH:mm')}
|
{format(cambio.fecha, 'HH:mm')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
|||||||
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import type { IASugerencia } from '@/types/asignatura'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
useUpdateAsignatura,
|
|
||||||
useSubject,
|
|
||||||
useUpdateSubjectRecommendation,
|
|
||||||
} from '@/data'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface ImprovementCardProps {
|
|
||||||
sug: IASugerencia
|
|
||||||
asignaturaId: string
|
|
||||||
onApplied: (campoKey: string) => void
|
|
||||||
isSelected?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImprovementCard({
|
|
||||||
sug,
|
|
||||||
asignaturaId,
|
|
||||||
onApplied,
|
|
||||||
}: ImprovementCardProps) {
|
|
||||||
const { data: asignatura } = useSubject(asignaturaId)
|
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
|
||||||
const updateRecommendation = useUpdateSubjectRecommendation()
|
|
||||||
|
|
||||||
const [isApplying, setIsApplying] = useState(false)
|
|
||||||
|
|
||||||
const handleApply = async () => {
|
|
||||||
if (!asignatura) return
|
|
||||||
|
|
||||||
setIsApplying(true)
|
|
||||||
try {
|
|
||||||
// 1. Identificar a qué columna debe ir el guardado
|
|
||||||
let patchData = {}
|
|
||||||
|
|
||||||
if (sug.campoKey === 'contenido_tematico') {
|
|
||||||
// Se guarda directamente en la columna contenido_tematico
|
|
||||||
patchData = { contenido_tematico: sug.valorSugerido }
|
|
||||||
} else if (sug.campoKey === 'criterios_de_evaluacion') {
|
|
||||||
// Se guarda directamente en la columna criterios_de_evaluacion
|
|
||||||
patchData = { criterios_de_evaluacion: sug.valorSugerido }
|
|
||||||
} else {
|
|
||||||
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
|
|
||||||
patchData = {
|
|
||||||
datos: {
|
|
||||||
...asignatura.datos,
|
|
||||||
[sug.campoKey]: sug.valorSugerido,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Ejecutar la actualización con la estructura correcta
|
|
||||||
await updateAsignatura.mutateAsync({
|
|
||||||
asignaturaId: asignaturaId as any,
|
|
||||||
patch: patchData as any,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Marcar la recomendación como aplicada
|
|
||||||
await updateRecommendation.mutateAsync({
|
|
||||||
mensajeId: sug.messageId,
|
|
||||||
campoAfectado: sug.campoKey,
|
|
||||||
})
|
|
||||||
console.log(sug.campoKey)
|
|
||||||
|
|
||||||
onApplied(sug.campoKey)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error al aplicar mejora:', error)
|
|
||||||
} finally {
|
|
||||||
setIsApplying(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
|
|
||||||
const renderContenido = (valor: any) => {
|
|
||||||
// Si no es un array, es texto simple
|
|
||||||
if (!Array.isArray(valor)) {
|
|
||||||
return <p className="italic">"{String(valor)}"</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
|
|
||||||
if (valor[0]?.hasOwnProperty('unidad')) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{valor.map((u: any, idx: number) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="bg-card border-primary/20 rounded-md border p-2 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="border-border/50 text-primary mb-1 flex items-center gap-2 border-b pb-1 text-[11px] font-bold">
|
|
||||||
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{u.temas?.map((t: any, tidx: number) => (
|
|
||||||
<li
|
|
||||||
key={tidx}
|
|
||||||
className="text-muted-foreground flex items-start justify-between gap-2 text-[10px]"
|
|
||||||
>
|
|
||||||
<span className="leading-tight">• {t.nombre}</span>
|
|
||||||
<span className="text-muted-foreground/70 flex shrink-0 items-center gap-0.5 font-mono">
|
|
||||||
<Clock size={10} /> {t.horasEstimadas}h
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
|
|
||||||
if (valor[0]?.hasOwnProperty('criterio')) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-muted-foreground/70 mb-1 flex items-center gap-2 text-[10px] font-bold uppercase">
|
|
||||||
<ListChecks size={12} /> Desglose de evaluación
|
|
||||||
</div>
|
|
||||||
{valor.map((c: any, idx: number) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="bg-card border-border flex items-center justify-between gap-3 rounded-md border p-2 shadow-sm"
|
|
||||||
>
|
|
||||||
<span className="text-foreground text-[11px] leading-tight">
|
|
||||||
{c.criterio}
|
|
||||||
</span>
|
|
||||||
<div className="border-accent/30 bg-accent/10 text-accent flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold">
|
|
||||||
{c.porcentaje}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* Opcional: Suma total para verificar que de 100% */}
|
|
||||||
<div className="text-muted-foreground/70 pt-1 text-right text-[9px] font-medium">
|
|
||||||
Total:{' '}
|
|
||||||
{valor.reduce(
|
|
||||||
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
|
|
||||||
0,
|
|
||||||
)}
|
|
||||||
%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caso por defecto (Array genérico)
|
|
||||||
return (
|
|
||||||
<pre className="text-[10px]">
|
|
||||||
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ESTADO APLICADO ---
|
|
||||||
if (sug.aceptada) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card border-border flex flex-col rounded-xl border p-3 opacity-80 shadow-sm">
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-4">
|
|
||||||
<span className="text-foreground text-sm font-bold">
|
|
||||||
{sug.campoNombre}
|
|
||||||
</span>
|
|
||||||
<div className="border-border bg-muted/50 text-muted-foreground flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium">
|
|
||||||
<Check size={14} />
|
|
||||||
Aplicado
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-primary/20 bg-primary/5 text-muted-foreground rounded-lg border p-3 text-xs leading-relaxed">
|
|
||||||
{renderContenido(sug.valorSugerido)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- ESTADO PENDIENTE ---
|
|
||||||
return (
|
|
||||||
<div className="bg-card border-primary/20 hover:border-primary/40 group flex flex-col rounded-xl border p-3 shadow-sm transition-all">
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-4">
|
|
||||||
<span className="border-primary/20 bg-primary/10 text-primary max-w-[150px] truncate rounded-lg border px-2.5 py-1 text-[10px] font-bold tracking-wider uppercase">
|
|
||||||
{sug.campoNombre}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={isApplying || !asignatura}
|
|
||||||
className="h-8 w-auto px-4 text-xs font-semibold shadow-sm"
|
|
||||||
onClick={handleApply}
|
|
||||||
>
|
|
||||||
{isApplying ? (
|
|
||||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Check size={14} className="mr-1.5" />
|
|
||||||
)}
|
|
||||||
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'border-border/60 bg-muted/30 text-muted-foreground rounded-lg border border-dashed p-3 text-xs leading-relaxed',
|
|
||||||
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{renderContenido(sug.valorSugerido)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
|
||||||
import { AlertTriangle } from 'lucide-react'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onConfirm: () => void
|
|
||||||
titulo?: string
|
|
||||||
descripcion?: string
|
|
||||||
}
|
|
||||||
export const AlertaConflicto = ({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
onConfirm,
|
|
||||||
titulo,
|
|
||||||
descripcion,
|
|
||||||
}: Props) => {
|
|
||||||
// Intentamos parsear el mensaje si viene como JSON para la lista de materias
|
|
||||||
let contenido
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(descripcion as any)
|
|
||||||
contenido = (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-slate-600">{data.main}</p>
|
|
||||||
<div className="flex flex-wrap gap-2 py-2">
|
|
||||||
{data.materias.map((m: string, i: number) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
|
|
||||||
{m}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs font-semibold text-slate-500">
|
|
||||||
¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
|
|
||||||
seriación)?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<AlertDialog.Portal>
|
|
||||||
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
|
|
||||||
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
|
|
||||||
<div className="mb-4 flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
|
|
||||||
<AlertTriangle className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
|
|
||||||
{titulo}
|
|
||||||
</AlertDialog.Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
|
|
||||||
<AlertDialog.Cancel asChild>
|
|
||||||
<Button variant="ghost">Cancelar</Button>
|
|
||||||
{/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
|
|
||||||
</AlertDialog.Cancel>
|
|
||||||
|
|
||||||
<AlertDialog.Action asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Mover de todos modos
|
|
||||||
</Button>
|
|
||||||
</AlertDialog.Action>
|
|
||||||
</div>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Portal>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import * as Icons from 'lucide-react'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
||||||
|
|
||||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { useSubject } from '@/data'
|
|
||||||
|
|
||||||
export function PasoBasicosClonadoInterno({
|
|
||||||
wizard,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
wizard: NewSubjectWizardState
|
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
|
||||||
}) {
|
|
||||||
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
|
||||||
const { data: source, isLoading, isError } = useSubject(sourceId)
|
|
||||||
|
|
||||||
const lastAppliedRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!source) return
|
|
||||||
if (lastAppliedRef.current === source.id) return
|
|
||||||
|
|
||||||
lastAppliedRef.current = source.id
|
|
||||||
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: {
|
|
||||||
...w.datosBasicos,
|
|
||||||
nombre: source.nombre,
|
|
||||||
codigo: source.codigo ?? '',
|
|
||||||
tipo: (source.tipo as any) ?? null,
|
|
||||||
creditos: source.creditos,
|
|
||||||
horasAcademicas: (source as any).horas_academicas ?? null,
|
|
||||||
horasIndependientes: (source as any).horas_independientes ?? null,
|
|
||||||
estructuraId: (source.estructura_id ??
|
|
||||||
w.datosBasicos.estructuraId) as any,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}, [onChange, source])
|
|
||||||
|
|
||||||
if (!sourceId) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Datos básicos</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-muted-foreground text-sm">
|
|
||||||
Selecciona una asignatura fuente para continuar.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Datos básicos</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-muted-foreground text-sm">
|
|
||||||
Cargando información de la asignatura fuente…
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !source) {
|
|
||||||
return (
|
|
||||||
<Card className="border-destructive/40">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-destructive flex items-center gap-2 text-base">
|
|
||||||
<Icons.AlertTriangle className="h-5 w-5" />
|
|
||||||
No se pudo cargar la fuente
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-muted-foreground text-sm">
|
|
||||||
Intenta seleccionar otra asignatura.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PasoBasicosForm
|
|
||||||
wizard={wizard}
|
|
||||||
onChange={onChange}
|
|
||||||
estructuraFuenteId={source.estructura_id ?? null}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Icons from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
||||||
@@ -22,11 +21,9 @@ import { cn } from '@/lib/utils'
|
|||||||
export function PasoBasicosForm({
|
export function PasoBasicosForm({
|
||||||
wizard,
|
wizard,
|
||||||
onChange,
|
onChange,
|
||||||
estructuraFuenteId,
|
|
||||||
}: {
|
}: {
|
||||||
wizard: NewSubjectWizardState
|
wizard: NewSubjectWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
estructuraFuenteId?: string | null
|
|
||||||
}) {
|
}) {
|
||||||
const { data: estructuras } = useSubjectEstructuras()
|
const { data: estructuras } = useSubjectEstructuras()
|
||||||
|
|
||||||
@@ -261,17 +258,6 @@ export function PasoBasicosForm({
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{estructuraFuenteId &&
|
|
||||||
wizard.datosBasicos.estructuraId &&
|
|
||||||
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
|
|
||||||
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
|
|
||||||
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
|
|
||||||
<span>
|
|
||||||
Es posible que se pierdan datos generales al seleccionar otra
|
|
||||||
estructura.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
|
||||||
import * as Icons from 'lucide-react'
|
|
||||||
import { useEffect, useMemo } from 'react'
|
|
||||||
import { useDebounce } from 'use-debounce'
|
|
||||||
|
|
||||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
||||||
import type { Database } from '@/types/supabase'
|
|
||||||
|
|
||||||
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
type SourceSubjectRow = {
|
|
||||||
id: string
|
|
||||||
nombre: string
|
|
||||||
codigo: string | null
|
|
||||||
creditos: number
|
|
||||||
tipo: any
|
|
||||||
plan_estudio_id: string
|
|
||||||
estructura_id: string | null
|
|
||||||
rank?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL = '__all__'
|
|
||||||
|
|
||||||
type SearchAsignaturasRow =
|
|
||||||
Database['public']['Functions']['search_asignaturas']['Returns'][number]
|
|
||||||
|
|
||||||
export function PasoFuenteClonadoInterno({
|
|
||||||
wizard,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
wizard: NewSubjectWizardState
|
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
|
||||||
}) {
|
|
||||||
const pageSize = 20
|
|
||||||
|
|
||||||
const facultadId = wizard.clonInterno?.facultadId ?? null
|
|
||||||
const carreraId = wizard.clonInterno?.carreraId ?? null
|
|
||||||
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
|
|
||||||
const search = wizard.clonInterno?.search ?? ''
|
|
||||||
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
|
|
||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 350)
|
|
||||||
|
|
||||||
const { data: catalogos } = useCatalogosPlanes()
|
|
||||||
|
|
||||||
const carrerasOptions = useMemo(() => {
|
|
||||||
const raw = catalogos?.carreras ?? []
|
|
||||||
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
|
|
||||||
}, [catalogos?.carreras, facultadId])
|
|
||||||
|
|
||||||
const planesQuery = usePlanes({
|
|
||||||
search: '',
|
|
||||||
facultadId: facultadId ?? 'todas',
|
|
||||||
carreraId: carreraId ?? 'todas',
|
|
||||||
estadoId: 'todos',
|
|
||||||
limit: 500,
|
|
||||||
offset: 0,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
|
|
||||||
const plansForFilter = planesQuery.data?.data ?? []
|
|
||||||
|
|
||||||
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'asignaturas',
|
|
||||||
'clone-source',
|
|
||||||
{
|
|
||||||
facultadId,
|
|
||||||
carreraId,
|
|
||||||
planOrigenId,
|
|
||||||
search: debouncedSearch,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
planIdsKey: needPlansForFilter
|
|
||||||
? plansForFilter.map((p) => p.id).join(',')
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
enabled: !needPlansForFilter || !planesQuery.isLoading,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
queryFn: async () => {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const from = (page - 1) * pageSize
|
|
||||||
const term = debouncedSearch.trim()
|
|
||||||
|
|
||||||
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
|
|
||||||
const { data, error } = await supabase.rpc('search_asignaturas', {
|
|
||||||
p_search: term,
|
|
||||||
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
|
|
||||||
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
|
|
||||||
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
|
|
||||||
p_limit: pageSize,
|
|
||||||
p_offset: from,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) throw new Error(error.message)
|
|
||||||
|
|
||||||
// Extraemos el conteo total de la primera fila.
|
|
||||||
// ESLint feliz: "data" ya no puede ser null a este punto.
|
|
||||||
const count = data.length > 0 ? Number(data[0].total_count) : 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
// ESLint feliz: map directo sobre data
|
|
||||||
data: data.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
nombre: r.nombre,
|
|
||||||
codigo: r.codigo,
|
|
||||||
creditos: Number(r.creditos),
|
|
||||||
tipo: r.tipo,
|
|
||||||
plan_estudio_id: r.plan_estudio_id,
|
|
||||||
estructura_id: null,
|
|
||||||
rank: r.rank,
|
|
||||||
})),
|
|
||||||
count,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const subjects = subjectsPaged?.data ?? []
|
|
||||||
const total = subjectsPaged?.count ?? 0
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// clamp page if results shrink
|
|
||||||
if (page > pageCount) {
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}, [onChange, page, pageCount])
|
|
||||||
|
|
||||||
const patchClonInterno = (
|
|
||||||
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
|
|
||||||
) =>
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const hasAnyFilter = Boolean(
|
|
||||||
facultadId || carreraId || planOrigenId || search.trim().length,
|
|
||||||
)
|
|
||||||
|
|
||||||
const clearDisabled = !hasAnyFilter
|
|
||||||
|
|
||||||
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
|
||||||
|
|
||||||
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<Card className="gap-4">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Fuente</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label>Facultad</Label>
|
|
||||||
<Select
|
|
||||||
value={facultadId ?? ALL}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
const next = val === ALL ? null : val
|
|
||||||
patchClonInterno({
|
|
||||||
facultadId: next,
|
|
||||||
carreraId: null,
|
|
||||||
planOrigenId: null,
|
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
resetSelection()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
|
||||||
<SelectValue placeholder="Todas" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ALL}>Todas</SelectItem>
|
|
||||||
{(catalogos?.facultades ?? []).map((f) => (
|
|
||||||
<SelectItem key={f.id} value={f.id}>
|
|
||||||
{f.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label>Carrera</Label>
|
|
||||||
<Select
|
|
||||||
value={carreraId ?? ALL}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
const next = val === ALL ? null : val
|
|
||||||
patchClonInterno({
|
|
||||||
carreraId: next,
|
|
||||||
planOrigenId: null,
|
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
resetSelection()
|
|
||||||
}}
|
|
||||||
disabled={!facultadId}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ALL}>Todas</SelectItem>
|
|
||||||
{carrerasOptions.map((c) => (
|
|
||||||
<SelectItem key={c.id} value={c.id}>
|
|
||||||
{c.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label>Plan</Label>
|
|
||||||
<Select
|
|
||||||
value={planOrigenId ?? ALL}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
const next = val === ALL ? null : val
|
|
||||||
patchClonInterno({ planOrigenId: next, page: 1 })
|
|
||||||
resetSelection()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
|
||||||
disabled={!carreraId && !facultadId}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Todos" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ALL}>Todos</SelectItem>
|
|
||||||
{(planesQuery.data?.data ?? []).map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label>Buscar</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="Nombre o código..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) =>
|
|
||||||
patchClonInterno({ search: e.target.value, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
patchClonInterno({
|
|
||||||
facultadId: null,
|
|
||||||
carreraId: null,
|
|
||||||
planOrigenId: null,
|
|
||||||
search: '',
|
|
||||||
page: 1,
|
|
||||||
asignaturaOrigenId: null,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
disabled={clearDisabled}
|
|
||||||
>
|
|
||||||
<Icons.X className="mr-2 h-4 w-4" />
|
|
||||||
Limpiar filtros
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
Selecciona una asignatura fuente (solo una).
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid max-h-80 gap-2 overflow-y-auto px-1">
|
|
||||||
{subjectsLoading ? (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
Cargando asignaturas…
|
|
||||||
</div>
|
|
||||||
) : subjects.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
No hay asignaturas con esos filtros.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
subjects.map((m) => {
|
|
||||||
const active = String(selectedId) === String(m.id)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={m.id}
|
|
||||||
className={cn(
|
|
||||||
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
|
|
||||||
active && 'border-primary bg-primary/5 ring-primary ring-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="sr-only"
|
|
||||||
type="radio"
|
|
||||||
name="asignaturaFuente"
|
|
||||||
checked={active}
|
|
||||||
onChange={() =>
|
|
||||||
patchClonInterno({ asignaturaOrigenId: m.id, page })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">{m.nombre}</div>
|
|
||||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{(m.codigo ? m.codigo : '—') +
|
|
||||||
' • ' +
|
|
||||||
String(m.creditos) +
|
|
||||||
' créditos'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{active ? (
|
|
||||||
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
|
|
||||||
) : (
|
|
||||||
<span className="h-5 w-5 flex-none" aria-hidden />
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pageCount > 1 ? (
|
|
||||||
<Pagination03
|
|
||||||
page={page}
|
|
||||||
pageCount={pageCount}
|
|
||||||
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
qk,
|
qk,
|
||||||
useCreateSubjectManual,
|
useCreateSubjectManual,
|
||||||
subjects_get_maybe,
|
subjects_get_maybe,
|
||||||
subjects_get,
|
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
@@ -202,87 +201,6 @@ export function WizardControls({
|
|||||||
let startedWaiting = false
|
let startedWaiting = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
|
||||||
if (!wizard.plan_estudio_id) {
|
|
||||||
throw new Error('Plan de estudio inválido.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
|
|
||||||
if (!asignaturaOrigenId) {
|
|
||||||
throw new Error('Selecciona una asignatura fuente.')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wizard.datosBasicos.estructuraId) {
|
|
||||||
throw new Error('Estructura inválida.')
|
|
||||||
}
|
|
||||||
if (!wizard.datosBasicos.nombre.trim()) {
|
|
||||||
throw new Error('Nombre inválido.')
|
|
||||||
}
|
|
||||||
if (wizard.datosBasicos.tipo == null) {
|
|
||||||
throw new Error('Tipo inválido.')
|
|
||||||
}
|
|
||||||
if (wizard.datosBasicos.creditos == null) {
|
|
||||||
throw new Error('Créditos inválidos.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const fuente = await subjects_get(asignaturaOrigenId as any)
|
|
||||||
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
|
|
||||||
|
|
||||||
const payload: TablesInsert<'asignaturas'> = {
|
|
||||||
plan_estudio_id: wizard.plan_estudio_id,
|
|
||||||
estructura_id: wizard.datosBasicos.estructuraId,
|
|
||||||
codigo: codigo ? codigo : null,
|
|
||||||
nombre: wizard.datosBasicos.nombre,
|
|
||||||
tipo: wizard.datosBasicos.tipo,
|
|
||||||
creditos: wizard.datosBasicos.creditos,
|
|
||||||
datos: (fuente as any).datos,
|
|
||||||
contenido_tematico: (fuente as any).contenido_tematico,
|
|
||||||
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
|
|
||||||
tipo_origen: 'CLONADO_INTERNO',
|
|
||||||
meta_origen: {
|
|
||||||
...(fuente as any).meta_origen,
|
|
||||||
asignatura_origen_id: fuente.id,
|
|
||||||
plan_origen_id: (fuente as any).plan_estudio_id,
|
|
||||||
},
|
|
||||||
horas_academicas:
|
|
||||||
wizard.datosBasicos.horasAcademicas ??
|
|
||||||
(fuente as any).horas_academicas ??
|
|
||||||
null,
|
|
||||||
horas_independientes:
|
|
||||||
wizard.datosBasicos.horasIndependientes ??
|
|
||||||
(fuente as any).horas_independientes ??
|
|
||||||
null,
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('payload:', payload)
|
|
||||||
|
|
||||||
const { data: inserted, error: insertError } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.insert(payload)
|
|
||||||
.select('id,plan_estudio_id')
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (insertError) throw new Error(insertError.message)
|
|
||||||
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(wizard.plan_estudio_id),
|
|
||||||
})
|
|
||||||
|
|
||||||
navigate({
|
|
||||||
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
|
|
||||||
state: { showConfetti: true },
|
|
||||||
resetScroll: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
if (!wizard.plan_estudio_id) {
|
if (!wizard.plan_estudio_id) {
|
||||||
throw new Error('Plan de estudio inválido.')
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ export function LoginCard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
|
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
|
||||||
<div className="flex justify-center">
|
|
||||||
<img
|
|
||||||
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
|
|
||||||
alt="La Salle México"
|
|
||||||
className="mb-6 h-20 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 className="mb-1 text-center text-2xl font-semibold">
|
<h1 className="mb-1 text-center text-2xl font-semibold">
|
||||||
Iniciar sesión
|
Iniciar sesión
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -4,18 +4,20 @@ import type { LucideIcon } from 'lucide-react'
|
|||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils' // Asegúrate de tener tu utilidad cn
|
||||||
|
|
||||||
interface PlanEstudiosCardProps {
|
interface PlanEstudiosCardProps {
|
||||||
|
/** El componente del ícono importado de lucide-react (ej. BookOpen) */
|
||||||
Icono: LucideIcon
|
Icono: LucideIcon
|
||||||
nombrePrograma: string
|
nombrePrograma: string
|
||||||
nivel: string
|
nivel: string
|
||||||
ciclos: string | number
|
ciclos: string | number // Acepta "8" o "8 semestres"
|
||||||
facultad: string
|
facultad: string
|
||||||
estado: string
|
estado: string
|
||||||
|
/** Código hex o variable CSS (ej. "#ef4444" o "var(--primary)") */
|
||||||
claseColorEstado?: string
|
claseColorEstado?: string
|
||||||
colorEstadoHex?: string
|
|
||||||
colorFacultad: string
|
colorFacultad: string
|
||||||
|
/** Opcional: para manejar el click en la tarjeta */
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,62 +29,52 @@ export default function PlanEstudiosCard({
|
|||||||
facultad,
|
facultad,
|
||||||
estado,
|
estado,
|
||||||
claseColorEstado = '',
|
claseColorEstado = '',
|
||||||
colorEstadoHex,
|
|
||||||
colorFacultad,
|
colorFacultad,
|
||||||
onClick,
|
onClick,
|
||||||
}: PlanEstudiosCardProps) {
|
}: PlanEstudiosCardProps) {
|
||||||
const badgeStyle = colorEstadoHex
|
|
||||||
? ({
|
|
||||||
backgroundColor: colorEstadoHex,
|
|
||||||
borderColor: colorEstadoHex,
|
|
||||||
} as const)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
|
'group relative flex h-full cursor-pointer flex-col justify-between gap-2 overflow-hidden border-l-4 transition-all hover:shadow-lg',
|
||||||
)}
|
)}
|
||||||
|
// Aplicamos el color de la facultad dinámicamente al borde y un fondo muy sutil
|
||||||
|
style={{
|
||||||
|
borderLeftColor: colorFacultad,
|
||||||
|
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 95%)`, // Truco CSS moderno para fondo tintado
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex grow flex-col">
|
<CardHeader className="pb-2">
|
||||||
<CardHeader className="pb-2">
|
{/* Ícono con el color de la facultad */}
|
||||||
{/* Círculo del ícono con el color de la facultad */}
|
<div
|
||||||
<div
|
className="mb-2 w-fit rounded-md p-2"
|
||||||
className="mb-2 w-fit rounded-full p-2.5"
|
style={{
|
||||||
style={{
|
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
||||||
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
}}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icono size={24} style={{ color: colorFacultad }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Título del Programa */}
|
|
||||||
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
|
|
||||||
{nombrePrograma}
|
|
||||||
</h4>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{nivel} • {ciclos}
|
|
||||||
</p>
|
|
||||||
<p>{facultad}</p>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardFooter className="flex items-center justify-between pt-0 pb-6">
|
|
||||||
<Badge
|
|
||||||
style={badgeStyle}
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-semibold',
|
|
||||||
!colorEstadoHex && claseColorEstado,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
|
<Icono size={24} style={{ color: colorFacultad }} />
|
||||||
{estado}
|
</div>
|
||||||
</span>
|
|
||||||
|
{/* Título del Programa */}
|
||||||
|
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
|
||||||
|
{nombrePrograma}
|
||||||
|
</h4>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
{nivel} • {ciclos}
|
||||||
|
</p>
|
||||||
|
<p>{facultad}</p>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="bg-background/50 flex items-center justify-between border-t px-6 pb-3 backdrop-blur-sm [.border-t]:pt-3">
|
||||||
|
<Badge className={`text-sm font-semibold ${claseColorEstado}`}>
|
||||||
|
{estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{/* <span className="text-foreground/80 text-sm font-semibold">
|
||||||
|
{estado}
|
||||||
|
</span> */}
|
||||||
|
|
||||||
{/* Flecha animada */}
|
{/* Flecha animada */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ export const ImprovementCard = ({
|
|||||||
|
|
||||||
if (onApplySuccess) onApplySuccess(key)
|
if (onApplySuccess) onApplySuccess(key)
|
||||||
|
|
||||||
|
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
|
||||||
if (dbMessageId) {
|
if (dbMessageId) {
|
||||||
updateAppliedStatus.mutate({
|
updateAppliedStatus.mutate({
|
||||||
conversacionId: dbMessageId,
|
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
|
||||||
campoAfectado: key,
|
campoAfectado: key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -80,18 +81,21 @@ export const ImprovementCard = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sug.key}
|
key={sug.key}
|
||||||
className={`bg-card rounded-2xl border p-5 shadow-sm transition-all ${
|
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
|
||||||
isApplied ? 'border-primary/30 bg-primary/5' : 'border-border'
|
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-foreground text-sm font-bold">{sug.label}</h3>
|
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleApply(sug.key, sug.newValue)}
|
onClick={() => handleApply(sug.key, sug.newValue)}
|
||||||
disabled={isApplied || !!isUpdating}
|
disabled={isApplied || !!isUpdating}
|
||||||
variant={isApplied ? 'secondary' : 'default'}
|
className={`h-8 rounded-full px-4 text-xs transition-all ${
|
||||||
className="h-8 rounded-full px-4 text-xs transition-all"
|
isApplied
|
||||||
|
? 'cursor-not-allowed bg-slate-100 text-slate-400'
|
||||||
|
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{isUpdating ? (
|
{isUpdating ? (
|
||||||
<Loader2 size={12} className="animate-spin" />
|
<Loader2 size={12} className="animate-spin" />
|
||||||
@@ -108,8 +112,8 @@ export const ImprovementCard = ({
|
|||||||
<div
|
<div
|
||||||
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
|
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
|
||||||
isApplied
|
isApplied
|
||||||
? 'border-primary/20 bg-primary/10 text-foreground'
|
? 'border-teal-100 bg-teal-50/50 text-slate-700'
|
||||||
: 'border-border bg-muted/50 text-muted-foreground'
|
: 'border-slate-200 bg-slate-50 text-slate-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sug.newValue}
|
{sug.newValue}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
|
export function PlanCard({ plan }: any) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#eaf6fa] rounded-2xl p-6 border hover:shadow-md transition">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<span className="text-sm text-gray-500">⚙ Ingeniería</span>
|
||||||
|
<StatusBadge status={plan.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{plan.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
{plan.subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm text-gray-500">
|
||||||
|
<span>{plan.cycles} ciclos</span>
|
||||||
|
<span>{plan.credits} créditos</span>
|
||||||
|
<span>➜</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { PlanCard } from './PlanCard'
|
||||||
|
|
||||||
|
const mockPlans = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Ingeniería en Sistemas',
|
||||||
|
level: 'Licenciatura',
|
||||||
|
status: 'Activo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Arquitectura',
|
||||||
|
level: 'Licenciatura',
|
||||||
|
status: 'Activo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Maestría en Educación',
|
||||||
|
level: 'Maestría',
|
||||||
|
status: 'Inactivo',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PlanGrid() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
Planes disponibles
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{mockPlans.map(plan => (
|
||||||
|
<PlanCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function StatCard({ icon, value, label }: any) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
<span className="text-2xl font-semibold">{value}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { StatCard } from "./StatCard";
|
||||||
|
|
||||||
|
export function StatsGrid() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<StatCard icon="📘" value="12" label="Planes Activos" />
|
||||||
|
<StatCard icon="🕒" value="4" label="En Revisión" />
|
||||||
|
<StatCard icon="✅" value="8" label="Aprobados" />
|
||||||
|
<StatCard icon="👥" value="6" label="Carreras" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const styles: any = {
|
||||||
|
revision: 'bg-blue-100 text-blue-700',
|
||||||
|
aprobado: 'bg-green-100 text-green-700',
|
||||||
|
borrador: 'bg-gray-200 text-gray-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-3 py-1 rounded-full font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status === 'revision'
|
||||||
|
? 'En Revisión'
|
||||||
|
: status === 'aprobado'
|
||||||
|
? 'Aprobado'
|
||||||
|
: 'Borrador'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { buttonVariants } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from '@/components/ui/pagination'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export type Pagination03Props = {
|
|
||||||
page: number
|
|
||||||
pageCount: number
|
|
||||||
onPageChange: (page: number) => void
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const toInt = (n: unknown, fallback: number) => {
|
|
||||||
const x = typeof n === 'number' ? n : Number(n)
|
|
||||||
return Number.isFinite(x) ? Math.floor(x) : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
|
|
||||||
if (pageCount <= 7) {
|
|
||||||
return Array.from({ length: pageCount }, (_, i) => i + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: Array<number | '...'> = []
|
|
||||||
const safePage = Math.min(Math.max(page, 1), pageCount)
|
|
||||||
|
|
||||||
items.push(1)
|
|
||||||
|
|
||||||
const start = Math.max(2, safePage - 1)
|
|
||||||
const end = Math.min(pageCount - 1, safePage + 1)
|
|
||||||
|
|
||||||
if (start > 2) items.push('...')
|
|
||||||
for (let p = start; p <= end; p++) items.push(p)
|
|
||||||
if (end < pageCount - 1) items.push('...')
|
|
||||||
|
|
||||||
items.push(pageCount)
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Pagination03({
|
|
||||||
page,
|
|
||||||
pageCount,
|
|
||||||
onPageChange,
|
|
||||||
className,
|
|
||||||
}: Pagination03Props) {
|
|
||||||
const safePageCount = Math.max(1, toInt(pageCount, 1))
|
|
||||||
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
|
|
||||||
|
|
||||||
const items = getPageItems(safePage, safePageCount)
|
|
||||||
const canPrev = safePage > 1
|
|
||||||
const canNext = safePage < safePageCount
|
|
||||||
|
|
||||||
const go = (p: number) => {
|
|
||||||
const next = Math.min(Math.max(p, 1), safePageCount)
|
|
||||||
if (next === safePage) return
|
|
||||||
onPageChange(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pagination className={className}>
|
|
||||||
<PaginationContent>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationPrevious
|
|
||||||
href="#"
|
|
||||||
className={cn(!canPrev && 'pointer-events-none opacity-50')}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!canPrev) return
|
|
||||||
go(safePage - 1)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
|
|
||||||
{items.map((it, idx) =>
|
|
||||||
it === '...' ? (
|
|
||||||
<PaginationItem key={`ellipsis-${idx}`}>
|
|
||||||
<PaginationEllipsis />
|
|
||||||
</PaginationItem>
|
|
||||||
) : (
|
|
||||||
<PaginationItem key={it}>
|
|
||||||
<PaginationLink
|
|
||||||
href="#"
|
|
||||||
isActive={it === safePage}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
go(it)
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
it === safePage
|
|
||||||
? cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: 'default',
|
|
||||||
size: 'icon',
|
|
||||||
}),
|
|
||||||
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{it}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
href="#"
|
|
||||||
className={cn(!canNext && 'pointer-events-none opacity-50')}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!canNext) return
|
|
||||||
go(safePage + 1)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Pagination03
|
|
||||||
@@ -7,7 +7,7 @@ import type { VariantProps } from 'class-variance-authority'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import {
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants, type Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
role="navigation"
|
|
||||||
aria-label="pagination"
|
|
||||||
data-slot="pagination"
|
|
||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"ul">) {
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
data-slot="pagination-content"
|
|
||||||
className={cn("flex flex-row items-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
|
||||||
return <li data-slot="pagination-item" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
|
||||||
isActive?: boolean
|
|
||||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
|
||||||
React.ComponentProps<"a">
|
|
||||||
|
|
||||||
function PaginationLink({
|
|
||||||
className,
|
|
||||||
isActive,
|
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: PaginationLinkProps) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
data-slot="pagination-link"
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: isActive ? "outline" : "ghost",
|
|
||||||
size,
|
|
||||||
}),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationPrevious({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) {
|
|
||||||
return (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon />
|
|
||||||
<span className="hidden sm:block">Previous</span>
|
|
||||||
</PaginationLink>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationNext({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) {
|
|
||||||
return (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to next page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:block">Next</span>
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</PaginationLink>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaginationEllipsis({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
data-slot="pagination-ellipsis"
|
|
||||||
className={cn("flex size-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
|
||||||
<span className="sr-only">More pages</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationPrevious,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationEllipsis,
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { CircleIcon } from "lucide-react"
|
|
||||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function RadioGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Root
|
|
||||||
data-slot="radio-group"
|
|
||||||
className={cn("grid gap-3", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RadioGroupItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Item
|
|
||||||
data-slot="radio-group-item"
|
|
||||||
className={cn(
|
|
||||||
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RadioGroupPrimitive.Indicator
|
|
||||||
data-slot="radio-group-indicator"
|
|
||||||
className="relative flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
|
|
||||||
</RadioGroupPrimitive.Indicator>
|
|
||||||
</RadioGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
|
||||||
@@ -25,7 +25,7 @@ export function WizardLayout({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="z-10 flex-none border-b bg-white">
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-3">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -36,10 +36,10 @@ export function WizardLayout({
|
|||||||
</button>
|
</button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{headerSlot ? <div className="px-6 pb-3">{headerSlot}</div> : null}
|
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 px-4 py-3 xl:px-6">
|
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,16 @@ export function WizardResponsiveHeader({
|
|||||||
wizard,
|
wizard,
|
||||||
methods,
|
methods,
|
||||||
titleOverrides,
|
titleOverrides,
|
||||||
hiddenStepIds,
|
|
||||||
}: {
|
}: {
|
||||||
wizard: any
|
wizard: any
|
||||||
methods: any
|
methods: any
|
||||||
titleOverrides?: Record<string, string>
|
titleOverrides?: Record<string, string>
|
||||||
hiddenStepIds?: Array<string>
|
|
||||||
}) {
|
}) {
|
||||||
const hidden = new Set(hiddenStepIds ?? [])
|
const idx = wizard.utils.getIndex(methods.current.id)
|
||||||
const visibleSteps = (wizard.steps as Array<any>).filter(
|
const totalSteps = wizard.steps.length
|
||||||
(s) => s && !hidden.has(s.id),
|
const currentIndex = idx + 1
|
||||||
)
|
const hasNextStep = idx < totalSteps - 1
|
||||||
|
const nextStep = wizard.steps[currentIndex]
|
||||||
const idx = visibleSteps.findIndex((s) => s.id === methods.current.id)
|
|
||||||
const safeIdx = idx >= 0 ? idx : 0
|
|
||||||
const totalSteps = visibleSteps.length
|
|
||||||
const currentIndex = Math.min(safeIdx + 1, totalSteps)
|
|
||||||
const hasNextStep = safeIdx < totalSteps - 1
|
|
||||||
const nextStep = visibleSteps[safeIdx + 1]
|
|
||||||
|
|
||||||
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
|
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
|
||||||
|
|
||||||
@@ -53,11 +45,10 @@ export function WizardResponsiveHeader({
|
|||||||
|
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
||||||
{visibleSteps.map((step: any, visibleIdx: number) => (
|
{wizard.steps.map((step: any) => (
|
||||||
<wizard.Stepper.Step
|
<wizard.Stepper.Step
|
||||||
key={step.id}
|
key={step.id}
|
||||||
of={step.id}
|
of={step.id}
|
||||||
icon={visibleIdx + 1}
|
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<wizard.Stepper.Title>
|
<wizard.Stepper.Title>
|
||||||
|
|||||||
@@ -359,19 +359,3 @@ export async function update_subject_conversation_status(
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update_subject_conversation_name(
|
|
||||||
conversacionId: string,
|
|
||||||
nuevoNombre: string,
|
|
||||||
) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('conversaciones_asignatura')
|
|
||||||
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
|
|
||||||
.eq('id', conversacionId)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,86 +1,52 @@
|
|||||||
// document.api.ts
|
// document.api.ts
|
||||||
|
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
const DOCUMENT_PDF_URL =
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
||||||
|
|
||||||
import { requireData, throwIfError } from './_helpers'
|
const DOCUMENT_PDF_ASIGNATURA_URL =
|
||||||
|
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
|
||||||
import type { Tables } from '@/types/supabase'
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
carbone_io_wrapper: 'carbone-io-wrapper',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
interface GeneratePdfParams {
|
interface GeneratePdfParams {
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
convertTo?: 'pdf'
|
|
||||||
}
|
}
|
||||||
interface GeneratePdfParamsAsignatura {
|
interface GeneratePdfParamsAsignatura {
|
||||||
asignatura_id: string
|
asignatura_id: string
|
||||||
convertTo?: 'pdf'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPlanPdf({
|
export async function fetchPlanPdf({
|
||||||
plan_estudio_id,
|
plan_estudio_id,
|
||||||
convertTo,
|
|
||||||
}: GeneratePdfParams): Promise<Blob> {
|
}: GeneratePdfParams): Promise<Blob> {
|
||||||
return await invokeEdge<Blob>(
|
const response = await fetch(DOCUMENT_PDF_URL, {
|
||||||
EDGE.carbone_io_wrapper,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
action: 'downloadReport',
|
'Content-Type': 'application/json',
|
||||||
plan_estudio_id,
|
|
||||||
body: convertTo ? { convertTo } : {},
|
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({ plan_estudio_id }),
|
||||||
headers: {
|
})
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
if (!response.ok) {
|
||||||
responseType: 'blob',
|
throw new Error('Error al generar el PDF')
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
// n8n devuelve el archivo → lo tratamos como blob
|
||||||
|
return await response.blob()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAsignaturaPdf({
|
export async function fetchAsignaturaPdf({
|
||||||
asignatura_id,
|
asignatura_id,
|
||||||
convertTo,
|
|
||||||
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
||||||
const supabase = supabaseBrowser()
|
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ asignatura_id }),
|
||||||
|
})
|
||||||
|
|
||||||
const { data, error } = await supabase
|
if (!response.ok) {
|
||||||
.from('asignaturas')
|
throw new Error('Error al generar el PDF')
|
||||||
.select('*')
|
|
||||||
.eq('id', asignatura_id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
const row = requireData(
|
|
||||||
data as Pick<
|
|
||||||
Tables<'asignaturas'>,
|
|
||||||
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
|
|
||||||
>,
|
|
||||||
'Asignatura no encontrada',
|
|
||||||
)
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
data: row,
|
|
||||||
}
|
}
|
||||||
if (convertTo) body.convertTo = convertTo
|
|
||||||
|
|
||||||
return await invokeEdge<Blob>(
|
// n8n devuelve el archivo → lo tratamos como blob
|
||||||
EDGE.carbone_io_wrapper,
|
return await response.blob()
|
||||||
{
|
|
||||||
action: 'downloadReport',
|
|
||||||
asignatura_id,
|
|
||||||
body: {
|
|
||||||
...body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
responseType: 'blob',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,35 +52,6 @@ const cleanText = (text: string) => {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const recalculoVectoresAsignaturasInFlight = new Set<string>()
|
|
||||||
|
|
||||||
function triggerRecalculoVectoresAsignaturasNonBlocking(
|
|
||||||
supabase: ReturnType<typeof supabaseBrowser>,
|
|
||||||
planId: UUID,
|
|
||||||
) {
|
|
||||||
const key = String(planId)
|
|
||||||
if (recalculoVectoresAsignaturasInFlight.has(key)) return
|
|
||||||
|
|
||||||
recalculoVectoresAsignaturasInFlight.add(key)
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const { error } = await supabase.rpc('recalcular_vectores_asignaturas')
|
|
||||||
if (error) {
|
|
||||||
// No debe bloquear ni romper el flujo principal.
|
|
||||||
console.warn(
|
|
||||||
'[recalcular_vectores_asignaturas] RPC error:',
|
|
||||||
error.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.warn('[recalcular_vectores_asignaturas] RPC failed:', err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
recalculoVectoresAsignaturasInFlight.delete(key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_list(
|
export async function plans_list(
|
||||||
filters: PlanListFilters = {},
|
filters: PlanListFilters = {},
|
||||||
): Promise<Paged<PlanEstudio>> {
|
): Promise<Paged<PlanEstudio>> {
|
||||||
@@ -221,9 +192,7 @@ export async function plan_lineas_list(
|
|||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('lineas_plan')
|
.from('lineas_plan')
|
||||||
.select(
|
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
|
||||||
'id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en,color',
|
|
||||||
)
|
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('orden', { ascending: true })
|
.order('orden', { ascending: true })
|
||||||
|
|
||||||
@@ -238,7 +207,7 @@ export async function plan_asignaturas_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,criterios_de_evaluacion,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id,search_vector',
|
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
|
||||||
)
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
@@ -246,13 +215,6 @@ export async function plan_asignaturas_list(
|
|||||||
.order('nombre', { ascending: true })
|
.order('nombre', { ascending: true })
|
||||||
|
|
||||||
throwIfError(error)
|
throwIfError(error)
|
||||||
|
|
||||||
// No bloqueante: si el primer registro viene sin vector, dispara el recalculo.
|
|
||||||
const first: any = (data as any)?.[0]
|
|
||||||
if (first && first.search_vector === null) {
|
|
||||||
triggerRecalculoVectoresAsignaturasNonBlocking(supabase, planId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
|||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
|
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,
|
||||||
planes_estudio(
|
planes_estudio(
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||||
@@ -232,7 +232,7 @@ export async function subjects_bibliografia_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('bibliografia_asignatura')
|
.from('bibliografia_asignatura')
|
||||||
.select(
|
.select(
|
||||||
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
|
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
|
||||||
)
|
)
|
||||||
.eq('asignatura_id', subjectId)
|
.eq('asignatura_id', subjectId)
|
||||||
.order('tipo', { ascending: true })
|
.order('tipo', { ascending: true })
|
||||||
@@ -499,7 +499,6 @@ export async function lineas_insert(linea: {
|
|||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
orden: number
|
orden: number
|
||||||
area?: string
|
area?: string
|
||||||
color?: string | null
|
|
||||||
}) {
|
}) {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -515,12 +514,7 @@ export async function lineas_insert(linea: {
|
|||||||
// Actualizar una línea existente
|
// Actualizar una línea existente
|
||||||
export async function lineas_update(
|
export async function lineas_update(
|
||||||
lineaId: string,
|
lineaId: string,
|
||||||
patch: {
|
patch: { nombre?: string; orden?: number; area?: string },
|
||||||
nombre?: string
|
|
||||||
orden?: number
|
|
||||||
area?: string
|
|
||||||
color?: string | null
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -591,49 +585,3 @@ export async function bibliografia_delete(id: string) {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkPrerrequisitoConflicts(
|
|
||||||
asignaturaId: string,
|
|
||||||
nuevoCiclo: number,
|
|
||||||
): Promise<Array<string>> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
// CORRECCIÓN 1: Agregamos 'id' al select
|
|
||||||
// CORRECCIÓN 2: Quitamos espacios en el .or()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select('id, nombre, numero_ciclo, prerrequisito_asignatura_id')
|
|
||||||
.or(`prerrequisito_asignatura_id.eq.${asignaturaId},id.eq.${asignaturaId}`)
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
// CORRECCIÓN 3: 'data' ahora está disponible porque lo desestructuramos arriba
|
|
||||||
if (!data) return []
|
|
||||||
|
|
||||||
const conflictos: Array<string> = []
|
|
||||||
|
|
||||||
data.forEach((asig) => {
|
|
||||||
// Caso 1: Materias que tienen a esta como prerrequisito (Hijas)
|
|
||||||
// "Si yo me muevo al ciclo 5, y mi hija está en el 4, se rompe la regla"
|
|
||||||
if (asig.prerrequisito_asignatura_id === asignaturaId) {
|
|
||||||
if (asig.numero_ciclo !== null && asig.numero_ciclo <= nuevoCiclo) {
|
|
||||||
conflictos.push(asig.nombre)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caso 2: El prerrequisito de la materia que estoy moviendo (Padre)
|
|
||||||
// "Si yo me muevo al ciclo 2, y mi padre está en el 3, se rompe la regla"
|
|
||||||
if (asig.id === asignaturaId && asig.prerrequisito_asignatura_id) {
|
|
||||||
const padre = data.find((p) => p.id === asig.prerrequisito_asignatura_id)
|
|
||||||
if (
|
|
||||||
padre &&
|
|
||||||
padre.numero_ciclo !== null &&
|
|
||||||
padre.numero_ciclo >= nuevoCiclo
|
|
||||||
) {
|
|
||||||
conflictos.push(padre.nombre)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return conflictos
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
getConversationBySubject,
|
getConversationBySubject,
|
||||||
ai_subject_chat_v2,
|
ai_subject_chat_v2,
|
||||||
create_subject_conversation,
|
create_subject_conversation,
|
||||||
update_subject_conversation_name,
|
|
||||||
} from '../api/ai.api'
|
} from '../api/ai.api'
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
|
|
||||||
@@ -321,17 +320,3 @@ export function useUpdateSubjectConversationStatus() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateSubjectConversationName() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: { id: string; nombre: string }) =>
|
|
||||||
update_subject_conversation_name(payload.id, payload.nombre),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
|
|
||||||
// También invalidamos los mensajes si el título se muestra en la cabecera
|
|
||||||
qc.invalidateQueries({ queryKey: ['subject-messages'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ai_generate_subject,
|
ai_generate_subject,
|
||||||
@@ -7,7 +6,6 @@ import {
|
|||||||
bibliografia_delete,
|
bibliografia_delete,
|
||||||
bibliografia_insert,
|
bibliografia_insert,
|
||||||
bibliografia_update,
|
bibliografia_update,
|
||||||
checkPrerrequisitoConflicts,
|
|
||||||
lineas_insert,
|
lineas_insert,
|
||||||
lineas_update,
|
lineas_update,
|
||||||
subjects_bibliografia_list,
|
subjects_bibliografia_list,
|
||||||
@@ -319,34 +317,3 @@ export function useDeleteBibliografia(asignaturaId: string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAsignaturaConflictos() {
|
|
||||||
const [isValidating, setIsValidating] = useState(false)
|
|
||||||
|
|
||||||
const validarCambioCiclo = async (
|
|
||||||
asignaturaId: string,
|
|
||||||
nuevoCiclo: number,
|
|
||||||
) => {
|
|
||||||
setIsValidating(true)
|
|
||||||
try {
|
|
||||||
const nombresConflictivos = await checkPrerrequisitoConflicts(
|
|
||||||
asignaturaId,
|
|
||||||
nuevoCiclo,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (nombresConflictivos.length > 0) {
|
|
||||||
const mensaje = `Si mueves esta materia al ciclo ${nuevoCiclo}, se perderá la seriación con:\n\n• ${nombresConflictivos.join('\n• ')}\n\n¿Deseas continuar?`
|
|
||||||
return confirm(mensaje) // Puedes usar un Modal de Shadcn aquí en lugar de confirm
|
|
||||||
}
|
|
||||||
|
|
||||||
return true // Sin conflictos
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
setIsValidating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { validarCambioCiclo, isValidating }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
|||||||
export type EdgeInvokeOptions = {
|
export type EdgeInvokeOptions = {
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EdgeFunctionError extends Error {
|
export class EdgeFunctionError extends Error {
|
||||||
@@ -27,55 +26,6 @@ export class EdgeFunctionError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soporta base64 puro o data:...;base64,...
|
|
||||||
function decodeBase64ToUint8Array(input: string): Uint8Array {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
const base64 = trimmed.startsWith('data:')
|
|
||||||
? trimmed.slice(trimmed.indexOf(',') + 1)
|
|
||||||
: trimmed
|
|
||||||
|
|
||||||
const bin = atob(base64)
|
|
||||||
const bytes = new Uint8Array(bin.length)
|
|
||||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripDataUrlPrefix(input: string): string {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
if (!trimmed.startsWith('data:')) return trimmed
|
|
||||||
const commaIdx = trimmed.indexOf(',')
|
|
||||||
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeBase64(s: string): boolean {
|
|
||||||
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
|
|
||||||
|
|
||||||
// base64 típico: solo chars permitidos y longitud razonable
|
|
||||||
if (t.length < 64) return false
|
|
||||||
return /^[A-Za-z0-9+/]+$/.test(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startsWithZip(bytes: Uint8Array): boolean {
|
|
||||||
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
|
|
||||||
}
|
|
||||||
|
|
||||||
function startsWithPdf(bytes: Uint8Array): boolean {
|
|
||||||
return (
|
|
||||||
bytes.length >= 5 &&
|
|
||||||
bytes[0] === 0x25 &&
|
|
||||||
bytes[1] === 0x50 &&
|
|
||||||
bytes[2] === 0x44 &&
|
|
||||||
bytes[3] === 0x46 &&
|
|
||||||
bytes[4] === 0x2d
|
|
||||||
) // "%PDF-"
|
|
||||||
}
|
|
||||||
|
|
||||||
function binaryStringToUint8Array(input: string): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(input.length)
|
|
||||||
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invokeEdge<TOut>(
|
export async function invokeEdge<TOut>(
|
||||||
functionName: string,
|
functionName: string,
|
||||||
body?:
|
body?:
|
||||||
@@ -92,16 +42,10 @@ export async function invokeEdge<TOut>(
|
|||||||
): Promise<TOut> {
|
): Promise<TOut> {
|
||||||
const supabase = client ?? supabaseBrowser()
|
const supabase = client ?? supabaseBrowser()
|
||||||
|
|
||||||
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
|
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||||
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
|
|
||||||
const invoke: any = (supabase.functions as any).invoke.bind(
|
|
||||||
supabase.functions,
|
|
||||||
)
|
|
||||||
const { data, error } = await invoke(functionName, {
|
|
||||||
body,
|
body,
|
||||||
method: opts.method ?? 'POST',
|
method: opts.method ?? 'POST',
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
responseType: opts.responseType,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -160,20 +104,5 @@ export async function invokeEdge<TOut>(
|
|||||||
throw new EdgeFunctionError(message, functionName, status, details)
|
throw new EdgeFunctionError(message, functionName, status, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.responseType === 'blob') {
|
|
||||||
const anyData: unknown = data
|
|
||||||
|
|
||||||
if (anyData instanceof Blob) {
|
|
||||||
return anyData as TOut
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new EdgeFunctionError(
|
|
||||||
'La Edge Function no devolvió un binario (Blob) válido.',
|
|
||||||
functionName,
|
|
||||||
undefined,
|
|
||||||
{ receivedType: typeof anyData, received: anyData },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as TOut
|
return data as TOut
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import * as Icons from 'lucide-react'
|
|||||||
|
|
||||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||||
|
|
||||||
import { PasoBasicosClonadoInterno } from '@/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx'
|
|
||||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
||||||
import { PasoFuenteClonadoInterno } from '@/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx'
|
|
||||||
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
||||||
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||||
@@ -65,12 +63,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
basicos: 'Sugerencias',
|
basicos: 'Sugerencias',
|
||||||
detalles: 'Estructura',
|
detalles: 'Estructura',
|
||||||
}
|
}
|
||||||
: wizard.tipoOrigen === 'CLONADO_INTERNO'
|
: undefined
|
||||||
? {
|
|
||||||
basicos: 'Fuente',
|
|
||||||
detalles: 'Datos básicos',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
||||||
@@ -106,21 +99,6 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
>
|
>
|
||||||
{({ methods }) => {
|
{({ methods }) => {
|
||||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||||
const stepId = methods.current.id
|
|
||||||
|
|
||||||
const disableNext =
|
|
||||||
wizard.isLoading ||
|
|
||||||
(stepId === 'metodo'
|
|
||||||
? !canContinueDesdeMetodo
|
|
||||||
: stepId === 'basicos'
|
|
||||||
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
|
||||||
? !canContinueDesdeDetalles
|
|
||||||
: !canContinueDesdeBasicos
|
|
||||||
: stepId === 'detalles'
|
|
||||||
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
|
||||||
? !canContinueDesdeBasicos
|
|
||||||
: !canContinueDesdeDetalles
|
|
||||||
: false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
@@ -140,7 +118,12 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
onPrev={() => methods.prev()}
|
onPrev={() => methods.prev()}
|
||||||
onNext={() => methods.next()}
|
onNext={() => methods.next()}
|
||||||
disablePrev={idx === 0 || wizard.isLoading}
|
disablePrev={idx === 0 || wizard.isLoading}
|
||||||
disableNext={disableNext}
|
disableNext={
|
||||||
|
wizard.isLoading ||
|
||||||
|
(idx === 0 && !canContinueDesdeMetodo) ||
|
||||||
|
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||||
|
(idx === 2 && !canContinueDesdeDetalles)
|
||||||
|
}
|
||||||
disableCreate={wizard.isLoading}
|
disableCreate={wizard.isLoading}
|
||||||
isLastStep={idx >= Wizard.steps.length - 1}
|
isLastStep={idx >= Wizard.steps.length - 1}
|
||||||
wizard={wizard}
|
wizard={wizard}
|
||||||
@@ -158,27 +141,13 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
|
|
||||||
{idx === 1 && (
|
{idx === 1 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||||
<PasoFuenteClonadoInterno
|
|
||||||
wizard={wizard}
|
|
||||||
onChange={setWizard}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
|
||||||
)}
|
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{idx === 2 && (
|
{idx === 2 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||||
<PasoBasicosClonadoInterno
|
|
||||||
wizard={wizard}
|
|
||||||
onChange={setWizard}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
|
||||||
)}
|
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,10 @@ export type NewSubjectWizardState = {
|
|||||||
}
|
}
|
||||||
sugerencias: Array<AsignaturaSugerida>
|
sugerencias: Array<AsignaturaSugerida>
|
||||||
clonInterno?: {
|
clonInterno?: {
|
||||||
facultadId?: string | null
|
facultadId?: string
|
||||||
carreraId?: string | null
|
carreraId?: string
|
||||||
planOrigenId?: string | null
|
planOrigenId?: string
|
||||||
asignaturaOrigenId?: string | null
|
asignaturaOrigenId?: string | null
|
||||||
search?: string
|
|
||||||
page?: number
|
|
||||||
}
|
}
|
||||||
clonTradicional?: {
|
clonTradicional?: {
|
||||||
archivoWordAsignaturaId: string | null
|
archivoWordAsignaturaId: string | null
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
|||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseContenidoTematicoToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const blocks: Array<string> = []
|
|
||||||
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
|
|
||||||
const unidad =
|
|
||||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
|
||||||
? item.unidad
|
|
||||||
: undefined
|
|
||||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
|
||||||
|
|
||||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
|
||||||
if (!header) continue
|
|
||||||
|
|
||||||
const lines: Array<string> = [header]
|
|
||||||
|
|
||||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
|
||||||
temas.forEach((tema, idx) => {
|
|
||||||
const temaNombre =
|
|
||||||
typeof tema === 'string'
|
|
||||||
? tema
|
|
||||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
|
||||||
? tema.nombre
|
|
||||||
: ''
|
|
||||||
if (!temaNombre) return
|
|
||||||
|
|
||||||
if (unidad != null) {
|
|
||||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
|
||||||
} else {
|
|
||||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks.push(lines.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.join('\n\n').trimEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const lines: Array<string> = []
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
|
|
||||||
const valueNum =
|
|
||||||
typeof item.porcentaje === 'number'
|
|
||||||
? item.porcentaje
|
|
||||||
: typeof item.porcentaje === 'string'
|
|
||||||
? Number(item.porcentaje)
|
|
||||||
: NaN
|
|
||||||
|
|
||||||
if (!label) continue
|
|
||||||
if (!Number.isFinite(valueNum)) continue
|
|
||||||
|
|
||||||
const v = Math.trunc(valueNum)
|
|
||||||
if (v < 1 || v > 100) continue
|
|
||||||
|
|
||||||
lines.push(`${label}: ${v}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columnParsers: Partial<
|
|
||||||
Record<string, (value: unknown) => string>
|
|
||||||
> = {
|
|
||||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
|
||||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ function RouteComponent() {
|
|||||||
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
|
4. px-4 md:px-6: Padding RESPONSIVO interno (seguro para móviles y desktop).
|
||||||
5. py-6: Padding vertical (opcional, para separarse del header).
|
5. py-6: Padding vertical (opcional, para separarse del header).
|
||||||
*/}
|
*/}
|
||||||
<div className="mx-auto flex w-full flex-col gap-4 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
nombre="Dr. Carlos Mendoza"
|
nombre="Dr. Carlos Mendoza"
|
||||||
rol="Jefe de Carrera"
|
rol="Jefe de Carrera"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Route = createFileRoute('/login')({
|
|||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="login-bg flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat">
|
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#7b0f1d] via-[#6b0d1a] to-[#3a050a]">
|
||||||
<LoginCard />
|
<LoginCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,24 +8,18 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, forwardRef } from 'react'
|
import { useState, useEffect, forwardRef } from 'react'
|
||||||
|
|
||||||
import type { Database } from '@/types/supabase'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
DropdownMenu,
|
||||||
SelectContent,
|
DropdownMenuContent,
|
||||||
SelectItem,
|
DropdownMenuItem,
|
||||||
SelectTrigger,
|
DropdownMenuTrigger,
|
||||||
SelectValue,
|
} from '@/components/ui/dropdown-menu'
|
||||||
} from '@/components/ui/select'
|
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { plans_get } from '@/data/api/plans.api'
|
import { plans_get } from '@/data/api/plans.api'
|
||||||
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
|
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
|
||||||
import { qk } from '@/data/query/keys'
|
import { qk } from '@/data/query/keys'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
type NivelPlanEstudio = Database['public']['Enums']['nivel_plan_estudio']
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||||
loader: async ({ context: { queryClient }, params: { planId } }) => {
|
loader: async ({ context: { queryClient }, params: { planId } }) => {
|
||||||
@@ -60,26 +54,28 @@ function RouteComponent() {
|
|||||||
|
|
||||||
// Estados locales para manejar la edición "en vivo" antes de persistir
|
// Estados locales para manejar la edición "en vivo" antes de persistir
|
||||||
const [nombrePlan, setNombrePlan] = useState('')
|
const [nombrePlan, setNombrePlan] = useState('')
|
||||||
const [nivelPlan, setNivelPlan] = useState<NivelPlanEstudio | undefined>(
|
const [nivelPlan, setNivelPlan] = useState('')
|
||||||
undefined,
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setNombrePlan(data.nombre || '')
|
setNombrePlan(data.nombre || '')
|
||||||
setNivelPlan(data.nivel)
|
setNivelPlan(data.nivel || '')
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const niveles: Array<NivelPlanEstudio> = [
|
const niveles = [
|
||||||
'Licenciatura',
|
'Licenciatura',
|
||||||
'Maestría',
|
'Maestría',
|
||||||
'Doctorado',
|
'Doctorado',
|
||||||
'Especialidad',
|
|
||||||
'Diplomado',
|
'Diplomado',
|
||||||
'Otro',
|
'Especialidad',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const persistChange = (patch: any) => {
|
||||||
|
mutate({ planId, patch })
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_CHARACTERS = 200
|
const MAX_CHARACTERS = 200
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
@@ -118,20 +114,20 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="min-h-screen bg-white">
|
||||||
{/* 1. Header Superior */}
|
{/* 1. Header Superior */}
|
||||||
<div className="bg-background/80 sticky top-0 z-20 border-b shadow-sm backdrop-blur-sm">
|
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
|
||||||
<div className="px-6 py-2">
|
<div className="px-6 py-2">
|
||||||
<Link
|
<Link
|
||||||
to="/planes"
|
to="/planes"
|
||||||
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-xs transition-colors"
|
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} /> Volver a planes
|
<ChevronLeft size={14} /> Volver a planes
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto space-y-8 p-4 md:px-6 md:pb-6 lg:px-8 lg:pb-8">
|
<div className="mx-auto max-w-400 space-y-8 p-8">
|
||||||
{/* 2. Header del Plan */}
|
{/* 2. Header del Plan */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
/* ===== SKELETON ===== */
|
/* ===== SKELETON ===== */
|
||||||
@@ -143,7 +139,7 @@ function RouteComponent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-foreground flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight">
|
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
|
||||||
{/* El prefijo "Nivel en" lo mantenemos simple */}
|
{/* El prefijo "Nivel en" lo mantenemos simple */}
|
||||||
<span className="shrink-0">{nivelPlan} en</span>
|
<span className="shrink-0">{nivelPlan} en</span>
|
||||||
<span
|
<span
|
||||||
@@ -152,105 +148,80 @@ function RouteComponent() {
|
|||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
aria-label="Nombre del plan"
|
|
||||||
title="Nombre del plan"
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste} // Añadido para controlar lo que pegan
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const nuevoNombre = e.currentTarget.textContent.trim()
|
const nuevoNombre =
|
||||||
|
e.currentTarget.textContent?.trim() || ''
|
||||||
setNombrePlan(nuevoNombre)
|
setNombrePlan(nuevoNombre)
|
||||||
if (nuevoNombre !== data?.nombre) {
|
if (nuevoNombre !== data?.nombre) {
|
||||||
mutate({ planId, patch: { nombre: nuevoNombre } })
|
mutate({ planId, patch: { nombre: nuevoNombre } })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent wrap-break-word whitespace-pre-wrap no-underline transition-colors outline-none select-text sm:inline-block sm:w-auto"
|
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
|
||||||
|
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
{nombrePlan}
|
{nombrePlan}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1 text-lg font-medium">
|
<p className="mt-1 text-lg font-medium text-slate-500">
|
||||||
{data?.carreras?.facultades?.nombre}{' '}
|
{data?.carreras?.facultades?.nombre}{' '}
|
||||||
{data?.carreras?.nombre_corto}
|
{data?.carreras?.nombre_corto}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
<div className="flex gap-2">
|
||||||
const estadoColorHex = (data?.estados_plan as any)?.color as
|
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
|
||||||
| string
|
{data?.estados_plan?.etiqueta}
|
||||||
| undefined
|
</Badge>
|
||||||
const badgeStyle = estadoColorHex
|
</div>
|
||||||
? ({
|
|
||||||
backgroundColor: estadoColorHex,
|
|
||||||
borderColor: estadoColorHex,
|
|
||||||
} as const)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
style={badgeStyle}
|
|
||||||
className={cn(
|
|
||||||
'text-sm font-semibold',
|
|
||||||
!estadoColorHex &&
|
|
||||||
'border-primary/20 bg-primary/10 text-primary hover:bg-primary/20',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
|
|
||||||
{data?.estados_plan?.etiqueta}
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 3. Cards de Información */}
|
{/* 3. Cards de Información */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||||
<div className="border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all">
|
<DropdownMenu>
|
||||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
<DropdownMenuTrigger asChild>
|
||||||
<GraduationCap className="text-muted-foreground" />
|
<InfoCard
|
||||||
</div>
|
icon={<GraduationCap className="text-slate-400" />}
|
||||||
<div className="min-w-0 flex-1">
|
label="Nivel"
|
||||||
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
|
|
||||||
Nivel
|
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
value={nivelPlan}
|
value={nivelPlan}
|
||||||
onValueChange={(value) => {
|
isEditable
|
||||||
const nuevoNivel = value as NivelPlanEstudio
|
/>
|
||||||
setNivelPlan(nuevoNivel)
|
</DropdownMenuTrigger>
|
||||||
if (nuevoNivel !== data?.nivel) {
|
|
||||||
mutate({ planId, patch: { nivel: nuevoNivel } })
|
<DropdownMenuContent className="w-48">
|
||||||
}
|
{niveles.map((n) => (
|
||||||
}}
|
<DropdownMenuItem
|
||||||
>
|
key={n}
|
||||||
<SelectTrigger className="w-full" size="sm">
|
onClick={() => {
|
||||||
<SelectValue placeholder="---" />
|
setNivelPlan(n)
|
||||||
</SelectTrigger>
|
if (n !== data?.nivel) {
|
||||||
<SelectContent>
|
mutate({ planId, patch: { nivel: n } })
|
||||||
{niveles.map((n) => (
|
}
|
||||||
<SelectItem key={n} value={n}>
|
}}
|
||||||
{n}
|
>
|
||||||
</SelectItem>
|
{n}
|
||||||
))}
|
</DropdownMenuItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</DropdownMenuContent>
|
||||||
</div>
|
</DropdownMenu>
|
||||||
</div>
|
|
||||||
|
|
||||||
<InfoCard
|
<InfoCard
|
||||||
icon={<Clock className="text-muted-foreground" />}
|
icon={<Clock className="text-slate-400" />}
|
||||||
label="Duración"
|
label="Duración"
|
||||||
value={`${data?.numero_ciclos || 0} Ciclos`}
|
value={`${data?.numero_ciclos || 0} Ciclos`}
|
||||||
/>
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
icon={<Hash className="text-muted-foreground" />}
|
icon={<Hash className="text-slate-400" />}
|
||||||
label="Créditos"
|
label="Créditos"
|
||||||
value="320"
|
value="320"
|
||||||
/>
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
icon={<CalendarDays className="text-muted-foreground" />}
|
icon={<CalendarDays className="text-slate-400" />}
|
||||||
label="Creación"
|
label="Creación"
|
||||||
value={data?.creado_en.split('T')[0]}
|
value={data?.creado_en?.split('T')[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -305,20 +276,20 @@ const InfoCard = forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
className={`border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all ${
|
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
|
||||||
isEditable
|
isEditable
|
||||||
? 'hover:border-primary/50 hover:bg-accent focus-visible:ring-primary/40 cursor-pointer focus:outline-none focus-visible:ring-2'
|
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
|
||||||
: ''
|
: ''
|
||||||
} ${className ?? ''}`}
|
} ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
|
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground truncate text-sm font-semibold">
|
<p className="truncate text-sm font-semibold text-slate-700">
|
||||||
{value || '---'}
|
{value || '---'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,8 +311,8 @@ function Tab({
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
params={params}
|
params={params}
|
||||||
className="text-muted-foreground hover:text-foreground border-b-2 border-transparent pb-3 text-sm font-medium transition-all"
|
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
|
||||||
activeProps={{ className: 'border-primary text-primary font-bold' }}
|
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
|
||||||
activeOptions={{
|
activeOptions={{
|
||||||
exact: true,
|
exact: true,
|
||||||
}}
|
}}
|
||||||
@@ -353,7 +324,7 @@ function Tab({
|
|||||||
|
|
||||||
function DatosGeneralesSkeleton() {
|
function DatosGeneralesSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-xl border">
|
<div className="rounded-xl border bg-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b px-5 py-3">
|
<div className="flex items-center justify-between border-b px-5 py-3">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-4 w-40" />
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const mapAsignaturas = (
|
|||||||
estado: asig.estado,
|
estado: asig.estado,
|
||||||
hd: asig.horas_academicas ?? 0,
|
hd: asig.horas_academicas ?? 0,
|
||||||
hi: asig.horas_independientes ?? 0,
|
hi: asig.horas_independientes ?? 0,
|
||||||
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
|
prerrequisitos: [],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ function AsignaturasPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<div className="container mx-auto space-y-6 px-6 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -152,7 +152,7 @@ function AsignaturasPage() {
|
|||||||
resetScroll: false,
|
resetScroll: false,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="shadow-md"
|
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
|
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
|
||||||
</Button>
|
</Button>
|
||||||
@@ -218,30 +218,26 @@ function AsignaturasPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50">
|
<TableRow className="bg-slate-50/50">
|
||||||
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
|
<TableHead className="w-30">Clave</TableHead>
|
||||||
<TableHead className="px-6 py-4">Nombre</TableHead>
|
<TableHead>Nombre</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
|
<TableHead className="text-center">Créditos</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
|
<TableHead className="text-center">Ciclo</TableHead>
|
||||||
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
|
<TableHead>Línea Curricular</TableHead>
|
||||||
<TableHead className="px-6 py-4">Tipo</TableHead>
|
<TableHead>Tipo</TableHead>
|
||||||
<TableHead className="px-6 py-4">Estado</TableHead>
|
<TableHead>Estado</TableHead>
|
||||||
<TableHead className="w-12.5 px-6 py-4"></TableHead>
|
<TableHead className="w-12.5"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAsignaturas.length === 0 ? (
|
{filteredAsignaturas.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
|
<TableCell colSpan={8} className="h-40 text-center">
|
||||||
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
|
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
||||||
<BookOpen className="h-10 w-10 opacity-20" />
|
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
||||||
<div>
|
<p className="font-medium">No se encontraron asignaturas</p>
|
||||||
<p className="font-medium">
|
<p className="text-xs">
|
||||||
No se encontraron asignaturas
|
Intenta cambiar los filtros de búsqueda
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs">
|
|
||||||
Intenta cambiar los filtros de búsqueda
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -255,25 +251,25 @@ function AsignaturasPage() {
|
|||||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
params: {
|
params: {
|
||||||
planId,
|
planId,
|
||||||
asignaturaId: asignatura.id,
|
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
realId: asignatura.id,
|
realId: asignatura.id, // 👈 ID largo oculto
|
||||||
asignaturaId: asignatura.id,
|
asignaturaId: asignatura.id,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
|
<TableCell className="font-mono text-xs font-bold text-slate-400">
|
||||||
{asignatura.clave}
|
{asignatura.clave}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 font-semibold text-slate-700">
|
<TableCell className="font-semibold text-slate-700">
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 text-center font-medium">
|
<TableCell className="text-center font-medium">
|
||||||
{asignatura.creditos}
|
{asignatura.creditos}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 text-center">
|
<TableCell className="text-center">
|
||||||
{asignatura.ciclo ? (
|
{asignatura.ciclo ? (
|
||||||
<Badge variant="outline" className="font-normal">
|
<Badge variant="outline" className="font-normal">
|
||||||
Ciclo {asignatura.ciclo}
|
Ciclo {asignatura.ciclo}
|
||||||
@@ -282,10 +278,10 @@ function AsignaturasPage() {
|
|||||||
<span className="text-slate-300">—</span>
|
<span className="text-slate-300">—</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4 text-sm text-slate-600">
|
<TableCell className="text-sm text-slate-600">
|
||||||
{getLineaNombre(asignatura.lineaCurricularId)}
|
{getLineaNombre(asignatura.lineaCurricularId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
||||||
@@ -293,7 +289,7 @@ function AsignaturasPage() {
|
|||||||
{tipoConfig[asignatura.tipo].label}
|
{tipoConfig[asignatura.tipo].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
||||||
@@ -301,7 +297,7 @@ function AsignaturasPage() {
|
|||||||
{statusConfig[asignatura.estado].label}
|
{statusConfig[asignatura.estado].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6 py-4">
|
<TableCell>
|
||||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<ChevronRight className="h-5 w-5 text-slate-400" />
|
<ChevronRight className="h-5 w-5 text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
FileJson,
|
FileJson,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { usePlan } from '@/data'
|
|
||||||
import { fetchPlanPdf } from '@/data/api/document.api'
|
import { fetchPlanPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
||||||
@@ -21,41 +20,30 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
||||||
const { data: plan } = usePlan(planId)
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const pdfUrlRef = useRef<string | null>(null)
|
|
||||||
const isMountedRef = useRef<boolean>(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
|
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isMountedRef.current) setIsLoading(true)
|
setIsLoading(true)
|
||||||
const pdfBlob = await fetchPlanPdf({
|
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
|
||||||
plan_estudio_id: planId,
|
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
// Limpiar URL anterior si existe para evitar fugas de memoria
|
||||||
pdfUrlRef.current = url
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
|
|
||||||
setPdfUrl(url)
|
setPdfUrl(url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando preview:', error)
|
console.error('Error cargando preview:', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [planId])
|
}, [planId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true
|
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
@@ -63,13 +51,12 @@ function RouteComponent() {
|
|||||||
try {
|
try {
|
||||||
const pdfBlob = await fetchPlanPdf({
|
const pdfBlob = await fetchPlanPdf({
|
||||||
plan_estudio_id: planId,
|
plan_estudio_id: planId,
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `${planFileBaseName}.pdf`
|
link.download = 'plan_estudios.pdf'
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
|
|
||||||
@@ -80,29 +67,8 @@ function RouteComponent() {
|
|||||||
alert('No se pudo generar el PDF')
|
alert('No se pudo generar el PDF')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadWord = async () => {
|
|
||||||
try {
|
|
||||||
const docBlob = await fetchPlanPdf({
|
|
||||||
plan_estudio_id: planId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(docBlob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `${planFileBaseName}.docx`
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
|
|
||||||
link.remove()
|
|
||||||
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
alert('No se pudo generar el Word')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30">
|
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
||||||
{/* HEADER DE ACCIONES */}
|
{/* HEADER DE ACCIONES */}
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -122,13 +88,12 @@ function RouteComponent() {
|
|||||||
>
|
>
|
||||||
<RefreshCcw size={16} /> Regenerar
|
<RefreshCcw size={16} /> Regenerar
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="gap-2" onClick={handleDownloadWord}>
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<Download size={16} /> Descargar Word
|
<Download size={16} /> Descargar Word
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||||
onClick={handleDownloadPdf}
|
onClick={handleDownloadPdf}
|
||||||
>
|
>
|
||||||
<Download size={16} /> Descargar PDF
|
<Download size={16} /> Descargar PDF
|
||||||
@@ -174,7 +139,7 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
|
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||||
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
||||||
@@ -184,7 +149,7 @@ function RouteComponent() {
|
|||||||
/* 3. VISOR DE PDF REAL */
|
/* 3. VISOR DE PDF REAL */
|
||||||
<iframe
|
<iframe
|
||||||
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
||||||
className="h-250 w-full max-w-250 border-none shadow-2xl"
|
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -198,24 +163,6 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFileBaseName(input: string): string {
|
|
||||||
const text = String(input)
|
|
||||||
const withoutControlChars = Array.from(text)
|
|
||||||
.filter((ch) => {
|
|
||||||
const code = ch.charCodeAt(0)
|
|
||||||
return code >= 32 && code !== 127
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
const cleaned = withoutControlChars
|
|
||||||
.replace(/[<>:"/\\|?*]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/g, '')
|
|
||||||
|
|
||||||
return (cleaned || 'documento').slice(0, 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Componente pequeño para las tarjetas de estado superior
|
// Componente pequeño para las tarjetas de estado superior
|
||||||
function StatusCard({
|
function StatusCard({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function RouteComponent() {
|
|||||||
console.log(rawData)
|
console.log(rawData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6 p-6">
|
||||||
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
{/* Header Informativo (Opcional, si no viene del layout padre) */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -146,7 +146,7 @@ function RouteComponent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full" disabled>
|
<Button className="w-full bg-teal-600 hover:bg-teal-700" disabled>
|
||||||
Avanzar a Revisión Expertos
|
Avanzar a Revisión Expertos
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Loader2,
|
Loader2,
|
||||||
Clock,
|
Clock,
|
||||||
|
Eye,
|
||||||
History,
|
History,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -36,24 +37,24 @@ const getEventConfig = (tipo: string, campo: string) => {
|
|||||||
return {
|
return {
|
||||||
label: 'Creación',
|
label: 'Creación',
|
||||||
icon: <PlusCircle className="h-4 w-4" />,
|
icon: <PlusCircle className="h-4 w-4" />,
|
||||||
color: 'primary',
|
color: 'teal',
|
||||||
}
|
}
|
||||||
if (campo === 'estado')
|
if (campo === 'estado')
|
||||||
return {
|
return {
|
||||||
label: 'Cambio de estado',
|
label: 'Cambio de estado',
|
||||||
icon: <GitBranch className="h-4 w-4" />,
|
icon: <GitBranch className="h-4 w-4" />,
|
||||||
color: 'secondary',
|
color: 'blue',
|
||||||
}
|
}
|
||||||
if (campo === 'datos')
|
if (campo === 'datos')
|
||||||
return {
|
return {
|
||||||
label: 'Edición de Datos',
|
label: 'Edición de Datos',
|
||||||
icon: <Edit3 className="h-4 w-4" />,
|
icon: <Edit3 className="h-4 w-4" />,
|
||||||
color: 'accent',
|
color: 'amber',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: 'Actualización',
|
label: 'Actualización',
|
||||||
icon: <RefreshCw className="h-4 w-4" />,
|
icon: <RefreshCw className="h-4 w-4" />,
|
||||||
color: 'muted',
|
color: 'slate',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ function RouteComponent() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [rawData, structure, data])
|
}, [rawData])
|
||||||
|
|
||||||
const openCompareModal = (event: any) => {
|
const openCompareModal = (event: any) => {
|
||||||
setSelectedEvent(event)
|
setSelectedEvent(event)
|
||||||
@@ -119,16 +120,16 @@ function RouteComponent() {
|
|||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto">
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<div className="mb-8 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-foreground flex items-center gap-2 text-xl font-bold">
|
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
|
||||||
<Clock className="text-primary h-5 w-5" /> Historial de Cambios del
|
<Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
|
||||||
Plan
|
Plan
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -138,39 +139,25 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative space-y-0">
|
<div className="relative space-y-0">
|
||||||
<div className="bg-border absolute top-0 bottom-0 left-6 w-px md:left-9" />
|
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
|
||||||
{historyEvents.length === 0 ? (
|
{historyEvents.length === 0 ? (
|
||||||
<div className="text-muted-foreground ml-20 py-10">
|
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
|
||||||
No hay registros.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
historyEvents.map((event) => (
|
historyEvents.map((event) => (
|
||||||
<div
|
<div key={event.id} className="group relative flex gap-6 pb-8">
|
||||||
key={event.id}
|
<div className="relative z-10 flex h-18 flex-col items-center">
|
||||||
className="group relative flex gap-3 pb-8 md:gap-6"
|
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
||||||
>
|
|
||||||
<div className="relative z-10 flex flex-col items-center">
|
|
||||||
<div className="border-background bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 shadow-sm transition-colors">
|
|
||||||
{event.icon}
|
{event.icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
||||||
className="border-border hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
|
|
||||||
onClick={() => openCompareModal(event)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ')
|
|
||||||
openCompareModal(event)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
||||||
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-foreground text-sm font-bold">
|
<span className="text-sm font-bold text-slate-800">
|
||||||
{event.type}
|
{event.type}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -185,17 +172,26 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grupo de elementos alineados a la derecha */}
|
{/* Grupo de elementos alineados a la derecha */}
|
||||||
<div className="text-muted-foreground flex flex-wrap items-center gap-3 md:gap-4">
|
<div className="flex items-center gap-4 text-slate-500">
|
||||||
{/* Usuario e Icono */}
|
{/* Usuario e Icono */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<User className="h-3.5 w-3.5" />
|
<User className="h-3.5 w-3.5" />
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{event.user}
|
{event.user}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Botón Ver Cambios */}
|
||||||
|
<button
|
||||||
|
onClick={() => openCompareModal(event)}
|
||||||
|
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
|
||||||
|
<span>Ver cambios</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
|
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
|
||||||
<span className="text-muted-foreground/70 hidden text-[11px] lg:block">
|
<span className="hidden text-[11px] text-slate-400 md:block">
|
||||||
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,7 +199,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
{/* LÍNEA INFERIOR: Descripción */}
|
{/* LÍNEA INFERIOR: Descripción */}
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-sm text-slate-600">
|
||||||
{event.description}
|
{event.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -214,16 +210,16 @@ function RouteComponent() {
|
|||||||
<div className="mt-2 flex items-center gap-1.5">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-destructive/10 text-destructive px-1.5 text-[9px]"
|
className="bg-red-50 px-1.5 text-[9px] text-red-700"
|
||||||
>
|
>
|
||||||
{event.details.from}
|
{event.details.from}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-muted-foreground/70 text-[10px]">
|
<span className="text-[10px] text-slate-400">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-primary/10 text-primary px-1.5 text-[9px]"
|
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
|
||||||
>
|
>
|
||||||
{event.details.to}
|
{event.details.to}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -237,8 +233,8 @@ function RouteComponent() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{historyEvents.length > 0 && (
|
{historyEvents.length > 0 && (
|
||||||
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
|
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-xs text-slate-500">
|
||||||
Mostrando {rawData.length} de {totalRecords} cambios
|
Mostrando {rawData.length} de {totalRecords} cambios
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -256,7 +252,7 @@ function RouteComponent() {
|
|||||||
Anterior
|
Anterior
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<span className="text-foreground text-sm font-medium">
|
<span className="text-sm font-medium text-slate-700">
|
||||||
Página {page + 1} de {totalPages || 1}
|
Página {page + 1} de {totalPages || 1}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -267,6 +263,7 @@ function RouteComponent() {
|
|||||||
setPage((p) => p + 1)
|
setPage((p) => p + 1)
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}}
|
}}
|
||||||
|
// Ahora se deshabilita si llegamos a la última página real
|
||||||
disabled={page + 1 >= totalPages || isLoading}
|
disabled={page + 1 >= totalPages || isLoading}
|
||||||
>
|
>
|
||||||
Siguiente
|
Siguiente
|
||||||
@@ -280,9 +277,9 @@ function RouteComponent() {
|
|||||||
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
|
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader className="bg-muted/50 border-b p-6">
|
<DialogHeader className="border-b bg-slate-50/50 p-6">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<History className="text-primary h-5 w-5" /> Comparación de
|
<History className="h-5 w-5 text-teal-600" /> Comparación de
|
||||||
Versiones
|
Versiones
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
|
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
|
||||||
@@ -301,16 +298,17 @@ function RouteComponent() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="grid h-full grid-cols-2 gap-6">
|
<div className="grid h-full grid-cols-2 gap-6">
|
||||||
|
{/* Lado Antes */}
|
||||||
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
|
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
|
||||||
{selectedEvent?.details.from && (
|
{selectedEvent?.details.from && (
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
|
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
||||||
<div className="bg-destructive h-2 w-2 rounded-full" />
|
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||||
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
||||||
Versión Anterior
|
Versión Anterior
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-destructive/20 bg-destructive/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
||||||
{renderValue(selectedEvent.details.from)}
|
{renderValue(selectedEvent.details.from)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,22 +316,27 @@ function RouteComponent() {
|
|||||||
|
|
||||||
{/* Lado Después */}
|
{/* Lado Después */}
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
|
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
||||||
<div className="bg-primary h-2 w-2 rounded-full" />
|
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||||
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
|
||||||
Nueva Versión
|
Nueva Versión
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-primary/20 bg-primary/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
||||||
{renderValue(selectedEvent?.details.to)}
|
{renderValue(selectedEvent?.details.to)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/30 flex justify-center border-t p-4">
|
<div className="flex justify-center border-t bg-slate-50 p-4">
|
||||||
<Badge variant="outline" className="font-mono text-[10px]">
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
Campo: {selectedEvent?.campo}
|
Campo: {selectedEvent?.campo}
|
||||||
|
{console.log(
|
||||||
|
data?.estructuras_plan?.definicion?.properties?.[
|
||||||
|
selectedEvent?.campo
|
||||||
|
]?.title,
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import {
|
|||||||
useNavigate,
|
useNavigate,
|
||||||
useLocation,
|
useLocation,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
|
// import confetti from 'canvas-confetti'
|
||||||
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
|
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { usePlan, useUpdatePlanFields } from '@/data'
|
import { usePlan, useUpdatePlanFields } from '@/data'
|
||||||
|
|
||||||
|
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/')({
|
||||||
component: DatosGeneralesPage,
|
component: DatosGeneralesPage,
|
||||||
})
|
})
|
||||||
@@ -32,20 +34,21 @@ function DatosGeneralesPage() {
|
|||||||
const { planId } = Route.useParams()
|
const { planId } = Route.useParams()
|
||||||
const { data, isLoading } = usePlan(planId)
|
const { data, isLoading } = usePlan(planId)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
// Inicializamos campos como un arreglo vacío
|
||||||
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
|
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [editValue, setEditValue] = useState('')
|
const [editValue, setEditValue] = useState('')
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const updatePlan = useUpdatePlanFields()
|
const updatePlan = useUpdatePlanFields()
|
||||||
|
// Confetti al llegar desde creación
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.state.showConfetti) {
|
if (location.state.showConfetti) {
|
||||||
lateralConfetti()
|
lateralConfetti()
|
||||||
window.history.replaceState({}, document.title)
|
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
|
||||||
}
|
}
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
|
// Efecto para transformar data?.datos en el arreglo de campos
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const definicion = data?.estructuras_plan?.definicion as any
|
const definicion = data?.estructuras_plan?.definicion as any
|
||||||
const properties = definicion?.properties
|
const properties = definicion?.properties
|
||||||
@@ -56,13 +59,18 @@ function DatosGeneralesPage() {
|
|||||||
if (properties && typeof properties === 'object') {
|
if (properties && typeof properties === 'object') {
|
||||||
let keys = Object.keys(properties)
|
let keys = Object.keys(properties)
|
||||||
|
|
||||||
|
// Ordenar llaves basado en la lista "required" si existe
|
||||||
if (Array.isArray(requiredOrder)) {
|
if (Array.isArray(requiredOrder)) {
|
||||||
keys = keys.sort((a, b) => {
|
keys = keys.sort((a, b) => {
|
||||||
const indexA = requiredOrder.indexOf(a)
|
const indexA = requiredOrder.indexOf(a)
|
||||||
const indexB = requiredOrder.indexOf(b)
|
const indexB = requiredOrder.indexOf(b)
|
||||||
|
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
|
||||||
if (indexA !== -1 && indexB === -1) return -1
|
if (indexA !== -1 && indexB === -1) return -1
|
||||||
|
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
|
||||||
if (indexA === -1 && indexB !== -1) return 1
|
if (indexA === -1 && indexB !== -1) return 1
|
||||||
|
// Si ambos están, comparar índices
|
||||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB
|
if (indexA !== -1 && indexB !== -1) return indexA - indexB
|
||||||
|
// Ninguno en la lista, mantener orden relativo
|
||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -100,14 +108,18 @@ function DatosGeneralesPage() {
|
|||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
// 3. Manejadores de acciones (Ahora como funciones locales)
|
||||||
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
|
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
|
||||||
|
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
|
||||||
if (editingId && editingId !== nuevoCampo.id) {
|
if (editingId && editingId !== nuevoCampo.id) {
|
||||||
const campoAnterior = campos.find((c) => c.id === editingId)
|
const campoAnterior = campos.find((c) => c.id === editingId)
|
||||||
if (campoAnterior && editValue !== campoAnterior.value) {
|
if (campoAnterior && editValue !== campoAnterior.value) {
|
||||||
|
// Solo guardamos si el valor realmente cambió
|
||||||
ejecutarGuardadoSilencioso(campoAnterior, editValue)
|
ejecutarGuardadoSilencioso(campoAnterior, editValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. ABRIMOS EL NUEVO CAMPO
|
||||||
setEditingId(nuevoCampo.id)
|
setEditingId(nuevoCampo.id)
|
||||||
setEditValue(nuevoCampo.value)
|
setEditValue(nuevoCampo.value)
|
||||||
}
|
}
|
||||||
@@ -116,7 +128,7 @@ function DatosGeneralesPage() {
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setEditValue('')
|
setEditValue('')
|
||||||
}
|
}
|
||||||
|
// Función auxiliar para procesar los datos (fuera o dentro del componente)
|
||||||
const prepararDatosActualizados = (
|
const prepararDatosActualizados = (
|
||||||
data: any,
|
data: any,
|
||||||
campo: DatosGeneralesField,
|
campo: DatosGeneralesField,
|
||||||
@@ -155,6 +167,7 @@ function DatosGeneralesPage() {
|
|||||||
patch: { datos: datosActualizados },
|
patch: { datos: datosActualizados },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Actualizar UI localmente
|
||||||
setCampos((prev) =>
|
setCampos((prev) =>
|
||||||
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
|
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
|
||||||
)
|
)
|
||||||
@@ -172,11 +185,13 @@ function DatosGeneralesPage() {
|
|||||||
currentValue !== null &&
|
currentValue !== null &&
|
||||||
'description' in currentValue
|
'description' in currentValue
|
||||||
) {
|
) {
|
||||||
|
// Caso 1: objeto con description
|
||||||
newValue = {
|
newValue = {
|
||||||
...currentValue,
|
...currentValue,
|
||||||
description: editValue,
|
description: editValue,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Caso 2: valor plano (string, number, etc)
|
||||||
newValue = editValue
|
newValue = editValue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +207,7 @@ function DatosGeneralesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// UI optimista
|
||||||
setCampos((prev) =>
|
setCampos((prev) =>
|
||||||
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
|
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
|
||||||
)
|
)
|
||||||
@@ -201,20 +217,19 @@ function DatosGeneralesPage() {
|
|||||||
setEditValue('')
|
setEditValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (campo: DatosGeneralesField) => {
|
const handleIARequest = (clave: string) => {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/planes/$planId/iaplan',
|
to: '/planes/$planId/iaplan',
|
||||||
params: {
|
params: {
|
||||||
planId: planId,
|
planId: planId, // o dinámico
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
campo_edit: campo.clave,
|
campo_edit: clave,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-500">
|
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-foreground text-lg font-semibold">
|
<h2 className="text-foreground text-lg font-semibold">
|
||||||
Datos Generales del Plan
|
Datos Generales del Plan
|
||||||
@@ -231,19 +246,19 @@ function DatosGeneralesPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={campo.id}
|
key={campo.id}
|
||||||
className={`bg-card rounded-xl border transition-all ${
|
className={`rounded-xl border transition-all ${
|
||||||
isEditing
|
isEditing
|
||||||
? 'border-primary ring-primary/20 shadow-lg ring-2'
|
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
|
||||||
: 'hover:shadow-md'
|
: 'bg-white hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header de la Card */}
|
{/* Header de la Card */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="bg-muted/30 flex items-center justify-between border-b px-5 py-3">
|
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<h3 className="text-foreground cursor-help text-sm font-medium">
|
<h3 className="cursor-help text-sm font-medium text-slate-700">
|
||||||
{campo.label}
|
{campo.label}
|
||||||
</h3>
|
</h3>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -253,7 +268,7 @@ function DatosGeneralesPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{campo.requerido && (
|
{campo.requerido && (
|
||||||
<span className="text-destructive text-xs">*</span>
|
<span className="text-xs text-red-500">*</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -264,7 +279,7 @@ function DatosGeneralesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-primary hover:text-primary/90 h-8 w-8"
|
className="h-8 w-8 text-teal-600"
|
||||||
onClick={() => handleIARequest(campo)}
|
onClick={() => handleIARequest(campo)}
|
||||||
>
|
>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
@@ -278,7 +293,7 @@ function DatosGeneralesPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => handleEdit(campo)}
|
onClick={() => handleEdit(campo)}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
@@ -309,7 +324,11 @@ function DatosGeneralesPage() {
|
|||||||
>
|
>
|
||||||
<X size={14} className="mr-1" /> Cancelar
|
<X size={14} className="mr-1" /> Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => handleSave(campo)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-teal-600 hover:bg-teal-700"
|
||||||
|
onClick={() => handleSave(campo)}
|
||||||
|
>
|
||||||
<Check size={14} className="mr-1" /> Guardar
|
<Check size={14} className="mr-1" /> Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,12 +336,12 @@ function DatosGeneralesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="min-h-25">
|
<div className="min-h-25">
|
||||||
{campo.value ? (
|
{campo.value ? (
|
||||||
<div className="text-muted-foreground text-sm leading-relaxed">
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
{campo.tipo === 'lista' ? (
|
{campo.tipo === 'lista' ? (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{campo.value.split('\n').map((item, i) => (
|
{campo.value.split('\n').map((item, i) => (
|
||||||
<li key={i} className="flex gap-2">
|
<li key={i} className="flex gap-2">
|
||||||
<span className="bg-primary mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" />
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -332,7 +351,7 @@ function DatosGeneralesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground/70 flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
<AlertCircle size={14} />
|
<AlertCircle size={14} />
|
||||||
<span>Sin contenido.</span>
|
<span>Sin contenido.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
|||||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
||||||
import { useSubject } from '@/data'
|
|
||||||
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -16,75 +15,48 @@ function RouteComponent() {
|
|||||||
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
|
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: asignatura } = useSubject(asignaturaId)
|
|
||||||
const asignaturaFileBaseName = sanitizeFileBaseName(
|
|
||||||
asignatura?.nombre ?? 'documento_sep',
|
|
||||||
)
|
|
||||||
|
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const pdfUrlRef = useRef<string | null>(null)
|
|
||||||
const isMountedRef = useRef<boolean>(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||||
|
|
||||||
const loadPdfPreview = useCallback(async () => {
|
const loadPdfPreview = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isMountedRef.current) setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
const pdfBlob = await fetchAsignaturaPdf({
|
const pdfBlob = await fetchAsignaturaPdf({
|
||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
|
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
setPdfUrl((prev) => {
|
||||||
pdfUrlRef.current = url
|
if (prev) window.URL.revokeObjectURL(prev)
|
||||||
setPdfUrl(url)
|
return url
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando PDF:', error)
|
console.error('Error cargando PDF:', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [asignaturaId])
|
}, [asignaturaId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true
|
|
||||||
loadPdfPreview()
|
loadPdfPreview()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false
|
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
|
||||||
}
|
}
|
||||||
}, [loadPdfPreview])
|
}, [loadPdfPreview])
|
||||||
|
|
||||||
const handleDownloadPdf = async () => {
|
const handleDownload = async () => {
|
||||||
const pdfBlob = await fetchAsignaturaPdf({
|
const pdfBlob = await fetchAsignaturaPdf({
|
||||||
asignatura_id: asignaturaId,
|
asignatura_id: asignaturaId,
|
||||||
convertTo: 'pdf',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(pdfBlob)
|
const url = window.URL.createObjectURL(pdfBlob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `${asignaturaFileBaseName}.pdf`
|
link.download = 'documento_sep.pdf'
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
link.remove()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDownloadWord = async () => {
|
|
||||||
const docBlob = await fetchAsignaturaPdf({
|
|
||||||
asignatura_id: asignaturaId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(docBlob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `${asignaturaFileBaseName}.docx`
|
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
link.remove()
|
link.remove()
|
||||||
@@ -105,28 +77,9 @@ function RouteComponent() {
|
|||||||
<DocumentoSEPTab
|
<DocumentoSEPTab
|
||||||
pdfUrl={pdfUrl}
|
pdfUrl={pdfUrl}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onDownloadPdf={handleDownloadPdf}
|
onDownload={handleDownload}
|
||||||
onDownloadWord={handleDownloadWord}
|
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
isRegenerating={isRegenerating}
|
isRegenerating={isRegenerating}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFileBaseName(input: string): string {
|
|
||||||
const text = String(input)
|
|
||||||
const withoutControlChars = Array.from(text)
|
|
||||||
.filter((ch) => {
|
|
||||||
const code = ch.charCodeAt(0)
|
|
||||||
return code >= 32 && code !== 127
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
const cleaned = withoutControlChars
|
|
||||||
.replace(/[<>:"/\\|?*]+/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/g, '')
|
|
||||||
|
|
||||||
return (cleaned || 'documento').slice(0, 150)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,22 +6,12 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
useRouterState,
|
useRouterState,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import {
|
import { ArrowLeft, GraduationCap } from 'lucide-react'
|
||||||
ArrowLeft,
|
|
||||||
GraduationCap,
|
|
||||||
Pencil,
|
|
||||||
Hash,
|
|
||||||
BookOpen,
|
|
||||||
CalendarDays,
|
|
||||||
Tag,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { AlertaConflicto } from '@/components/asignaturas/detalle/mapa/AlertaConflicto'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { lateralConfetti } from '@/components/ui/lateral-confetti'
|
import { lateralConfetti } from '@/components/ui/lateral-confetti'
|
||||||
import { useSubject, useUpdateAsignatura, usePlanAsignaturas } from '@/data'
|
import { useSubject, useUpdateAsignatura } from '@/data'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/planes/$planId/asignaturas/$asignaturaId',
|
'/planes/$planId/asignaturas/$asignaturaId',
|
||||||
@@ -29,186 +19,65 @@ export const Route = createFileRoute(
|
|||||||
component: AsignaturaLayout,
|
component: AsignaturaLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- 1. COMPONENTE PARA EDITAR EL TÍTULO SOBRE FONDO AZUL ---
|
function EditableHeaderField({
|
||||||
function InlineEditTitle({
|
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
|
||||||
onSave: (val: string) => void
|
|
||||||
}) {
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
|
||||||
const [tempVal, setTempVal] = useState(value)
|
|
||||||
|
|
||||||
useEffect(() => setTempVal(value), [value])
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
setIsEditing(false)
|
|
||||||
if (tempVal.trim() && tempVal !== value) onSave(tempVal.trim())
|
|
||||||
else setTempVal(value) // Revertir si está vacío
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={tempVal}
|
|
||||||
onChange={(e) => setTempVal(e.target.value)}
|
|
||||||
onBlur={handleSave}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSave()
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setTempVal(value)
|
|
||||||
setIsEditing(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
// Input estilizado para fondo oscuro: borde blanco sutil, texto blanco
|
|
||||||
className="focus:ring-primary/40 w-full rounded-md border-2 border-white/20 bg-transparent px-2 py-1 text-3xl font-bold text-white shadow-sm outline-none focus:ring-4"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<h1
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
// Texto blanco por defecto, fondo blanco sutil al hover
|
|
||||||
className="group flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 text-3xl font-bold text-white transition-colors hover:bg-white/5"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
{/* Lápiz blanco sutil */}
|
|
||||||
<Pencil className="h-5 w-5 text-white/50 opacity-0 transition-all group-hover:opacity-100 hover:text-white" />
|
|
||||||
</h1>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 2. COMPONENTE PARA EDITAR LOS BADGES SOBRE FONDO AZUL ---
|
|
||||||
function InlineEditBadge({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
suffix = '',
|
|
||||||
type = 'text',
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode
|
|
||||||
label: string
|
|
||||||
value: string | number
|
value: string | number
|
||||||
suffix?: string
|
|
||||||
type?: 'text' | 'number'
|
|
||||||
onSave: (val: string) => void
|
onSave: (val: string) => void
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const textValue = String(value)
|
||||||
const [tempVal, setTempVal] = useState(value)
|
|
||||||
|
|
||||||
useEffect(() => setTempVal(value), [value])
|
// Manejador para cuando el usuario termina de editar (pierde el foco)
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
|
||||||
const handleSave = () => {
|
const newValue = e.currentTarget.innerText
|
||||||
setIsEditing(false)
|
if (newValue !== textValue) {
|
||||||
if (String(tempVal).trim() !== String(value)) {
|
onSave(newValue)
|
||||||
onSave(String(tempVal))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing) {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
return (
|
if (e.key === 'Enter') {
|
||||||
// Contenedor del input con estética de badge oscuro
|
e.preventDefault()
|
||||||
<div className="focus:ring-primary/40 flex h-8 items-center gap-2 rounded-md border border-white/20 bg-white/5 px-3 shadow-sm ring-1 focus-within:ring-2">
|
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
|
||||||
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
|
}
|
||||||
{label}:
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type={type}
|
|
||||||
value={tempVal}
|
|
||||||
onChange={(e) => setTempVal(e.target.value)}
|
|
||||||
onBlur={handleSave}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSave()
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setTempVal(value)
|
|
||||||
setIsEditing(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
// Texto blanco dentro del input
|
|
||||||
className="w-16 bg-transparent text-sm font-semibold text-white outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
onClick={() => setIsEditing(true)}
|
<span
|
||||||
// Badge oscuro: borde blanco sutil, texto blanco, fondo más claro al hover
|
contentEditable
|
||||||
className="group flex h-8 items-center gap-2 rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white transition-all hover:border-white/20 hover:bg-white/10"
|
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
|
||||||
|
spellCheck={false}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
|
||||||
>
|
>
|
||||||
{/* Ícono blanco sutil */}
|
{textValue}
|
||||||
<span className="text-white/70">{icon}</span>
|
</span>
|
||||||
<span className="text-xs font-medium tracking-wider text-white/60 uppercase">
|
|
||||||
{label}:
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-white">
|
|
||||||
{value} {suffix}
|
|
||||||
</span>
|
|
||||||
{/* Lápiz blanco sutil */}
|
|
||||||
<Pencil className="h-3 w-3 text-white/50 opacity-0 transition-opacity group-hover:opacity-100" />
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DatosPlan {
|
interface DatosPlan {
|
||||||
nombre?: string
|
nombre?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function AsignaturaLayout() {
|
function AsignaturaLayout() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { asignaturaId, planId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
const { planId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: asignaturaApi, isLoading: loadingAsig } =
|
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||||
useSubject(asignaturaId)
|
useSubject(asignaturaId)
|
||||||
const { data: todasLasAsignaturas } = usePlanAsignaturas(planId)
|
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||||
const [confirmState, setConfirmState] = useState<{
|
|
||||||
isOpen: boolean
|
|
||||||
resolve: (value: boolean) => void
|
|
||||||
mensaje: string
|
|
||||||
} | null>(null)
|
|
||||||
const validarConInterrupcion = async (
|
|
||||||
nuevoCiclo: number,
|
|
||||||
): Promise<boolean> => {
|
|
||||||
if (!todasLasAsignaturas || !asignaturaApi) return true
|
|
||||||
|
|
||||||
const materiasConflicto = todasLasAsignaturas.filter((a) => {
|
|
||||||
const esPrerrequisitoConflictivo =
|
|
||||||
asignaturaApi.prerrequisito_asignatura_id === a.id &&
|
|
||||||
(a.numero_ciclo ?? 0) >= nuevoCiclo
|
|
||||||
|
|
||||||
const esDependienteConflictiva =
|
|
||||||
a.prerrequisito_asignatura_id === asignaturaApi.id &&
|
|
||||||
(a.numero_ciclo ?? 0) <= nuevoCiclo
|
|
||||||
|
|
||||||
return esPrerrequisitoConflictivo || esDependienteConflictiva
|
|
||||||
})
|
|
||||||
|
|
||||||
if (materiasConflicto.length === 0) return true
|
|
||||||
|
|
||||||
const listaNombres = materiasConflicto.map((m) => m.nombre)
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setConfirmState({
|
|
||||||
isOpen: true,
|
|
||||||
resolve,
|
|
||||||
mensaje: JSON.stringify({
|
|
||||||
main: `Mover "${asignaturaApi.nombre}" al ciclo ${nuevoCiclo} genera conflictos con:`,
|
|
||||||
materias: listaNombres,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
|
// Dentro de AsignaturaDetailPage
|
||||||
const [headerData, setHeaderData] = useState({
|
const [headerData, setHeaderData] = useState({
|
||||||
codigo: '',
|
codigo: '',
|
||||||
nombre: '',
|
nombre: '',
|
||||||
@@ -216,6 +85,7 @@ function AsignaturaLayout() {
|
|||||||
ciclo: 0,
|
ciclo: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sincronizar cuando llegue la API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturaApi) {
|
if (asignaturaApi) {
|
||||||
setHeaderData({
|
setHeaderData({
|
||||||
@@ -227,38 +97,28 @@ function AsignaturaLayout() {
|
|||||||
}
|
}
|
||||||
}, [asignaturaApi])
|
}, [asignaturaApi])
|
||||||
|
|
||||||
const handleUpdateHeader = async (key: string, value: string | number) => {
|
const handleUpdateHeader = (key: string, value: string | number) => {
|
||||||
// 1. Si es ciclo, validamos antes de hacer nada
|
|
||||||
if (key === 'ciclo') {
|
|
||||||
const nuevoCiclo = Number(value)
|
|
||||||
const acepto = await validarConInterrupcion(nuevoCiclo)
|
|
||||||
|
|
||||||
setConfirmState(null) // Cerramos el modal tras la respuesta
|
|
||||||
|
|
||||||
if (!acepto) {
|
|
||||||
// Revertimos el estado local al valor de la API si cancela
|
|
||||||
setHeaderData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
ciclo: asignaturaApi?.numero_ciclo ?? 0,
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Si no es ciclo o si aceptó el conflicto, procedemos
|
|
||||||
const newData = { ...headerData, [key]: value }
|
const newData = { ...headerData, [key]: value }
|
||||||
setHeaderData(newData)
|
setHeaderData(newData)
|
||||||
|
|
||||||
const patch: Record<string, any> =
|
const patch: Record<string, any> =
|
||||||
key === 'ciclo' ? { numero_ciclo: value } : { [key]: value }
|
key === 'ciclo'
|
||||||
|
? { numero_ciclo: value }
|
||||||
|
: {
|
||||||
|
[key]: value,
|
||||||
|
}
|
||||||
|
|
||||||
updateAsignatura.mutate({ asignaturaId, patch })
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathname = useRouterState({
|
const pathname = useRouterState({
|
||||||
select: (state) => state.location.pathname,
|
select: (state) => state.location.pathname,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Confetti al llegar desde creación IA
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((location.state as any)?.showConfetti) {
|
if ((location.state as any)?.showConfetti) {
|
||||||
lateralConfetti()
|
lateralConfetti()
|
||||||
@@ -268,120 +128,106 @@ function AsignaturaLayout() {
|
|||||||
|
|
||||||
if (loadingAsig) {
|
if (loadingAsig) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background text-foreground flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
|
||||||
Cargando asignatura...
|
Cargando asignatura...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si no hay datos y no está cargando, algo falló
|
||||||
if (!asignaturaApi) return null
|
if (!asignaturaApi) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div>
|
||||||
{/* HEADER DE LA ASIGNATURA CON TU FONDO AZUL HARDCODEADO */}
|
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||||
<section className="border-border border-b bg-[#0b1d3a] pt-6 pb-8">
|
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||||
<div className="mx-auto px-4 md:px-6 lg:px-8">
|
|
||||||
<Link
|
<Link
|
||||||
to="/planes/$planId/asignaturas"
|
to="/planes/$planId/asignaturas"
|
||||||
params={{ planId }}
|
params={{ planId }}
|
||||||
// Enlace blanco sutil
|
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
|
||||||
className="mb-4 flex w-fit items-center gap-2 text-sm text-white/70 transition-colors hover:text-white"
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" /> Volver al plan
|
<ArrowLeft className="h-4 w-4" /> Volver al plan
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex items-start justify-between gap-6">
|
||||||
{/* Título Editable (Texto blanco controlado dentro del componente) */}
|
<div className="space-y-3">
|
||||||
<div className="-ml-2">
|
{/* CÓDIGO EDITABLE */}
|
||||||
<InlineEditTitle
|
<Badge className="border border-blue-700 bg-blue-900/50">
|
||||||
value={headerData.nombre}
|
<EditableHeaderField
|
||||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
value={headerData.codigo}
|
||||||
/>
|
onSave={(val) => handleUpdateHeader('codigo', val)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Fila de Metadatos Alineados */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{/* Badge Estático del Tipo (Estilo oscuro sutil) */}
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="flex h-8 cursor-default items-center gap-1.5 border-white/10 bg-white/5 px-3 text-white"
|
|
||||||
>
|
|
||||||
<Tag size={12} className="text-white/70" />
|
|
||||||
{asignaturaApi.tipo}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Badges Editables (Texto blanco controlado dentro de los componentes) */}
|
{/* NOMBRE EDITABLE */}
|
||||||
<InlineEditBadge
|
<h1 className="text-3xl font-bold">
|
||||||
icon={<Hash size={14} />}
|
<EditableHeaderField
|
||||||
label="Código"
|
value={headerData.nombre}
|
||||||
value={headerData.codigo}
|
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||||
onSave={(val) => handleUpdateHeader('codigo', val)}
|
/>
|
||||||
/>
|
</h1>
|
||||||
|
|
||||||
<InlineEditBadge
|
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||||
icon={<BookOpen size={14} />}
|
<span className="flex items-center gap-1">
|
||||||
label="Créditos"
|
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||||
type="number"
|
<span className="text-blue-100">
|
||||||
value={headerData.creditos}
|
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
||||||
onSave={(val) =>
|
.nombre || ''}
|
||||||
handleUpdateHeader('creditos', parseInt(val) || 0)
|
</span>
|
||||||
}
|
</span>
|
||||||
/>
|
|
||||||
|
|
||||||
<InlineEditBadge
|
<span className="flex items-center gap-1">
|
||||||
icon={<CalendarDays size={14} />}
|
<span className="text-blue-100">
|
||||||
label="Semestre"
|
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
||||||
type="number"
|
.nombre ?? ''}
|
||||||
value={headerData.ciclo}
|
</span>
|
||||||
suffix="°"
|
</span>
|
||||||
onSave={(val) =>
|
</div>
|
||||||
handleUpdateHeader('ciclo', parseInt(val) || 0)
|
|
||||||
}
|
<p className="text-sm text-blue-300">
|
||||||
/>
|
Pertenece al plan:{' '}
|
||||||
|
<span className="cursor-pointer underline">
|
||||||
|
{asignaturaApi.planes_estudio?.nombre}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtítulo de contexto (Texto blanco sutil) */}
|
<div className="flex flex-col items-end gap-2 text-right">
|
||||||
<div className="mt-2 flex items-center gap-2 text-sm text-white/70">
|
{/* CRÉDITOS EDITABLES */}
|
||||||
<GraduationCap className="h-4 w-4 shrink-0 text-white/60" />
|
<Badge variant="secondary" className="gap-1">
|
||||||
<span>Pertenece al plan:</span>
|
<span className="inline-flex max-w-fit">
|
||||||
<span className="font-medium text-white">
|
<EditableHeaderField
|
||||||
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
|
value={headerData.creditos}
|
||||||
</span>
|
onSave={(val) =>
|
||||||
|
handleUpdateHeader('creditos', parseInt(val) || 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>créditos</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* SEMESTRE EDITABLE */}
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<EditableHeaderField
|
||||||
|
value={headerData.ciclo}
|
||||||
|
onSave={(val) =>
|
||||||
|
handleUpdateHeader('ciclo', parseInt(val) || 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>° ciclo</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge variant="secondary">{asignaturaApi.tipo}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* TABS NAVEGACIÓN (Se mantiene semántico para el cuerpo de la página)
|
|
||||||
<nav className="border-border bg-background/80 sticky top-0 z-20 border-b backdrop-blur-md">
|
|
||||||
<div className="mx-auto px-4 py-1 md:px-6 lg:px-8">
|
|
||||||
<div className="scrollbar-hide flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
|
|
||||||
*/}
|
|
||||||
{confirmState && (
|
|
||||||
<AlertaConflicto
|
|
||||||
isOpen={confirmState.isOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
confirmState.resolve(false)
|
|
||||||
setConfirmState(null)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onConfirm={() => confirmState.resolve(true)}
|
|
||||||
titulo="Conflicto de Seriación"
|
|
||||||
descripcion={confirmState.mensaje}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TABS */}
|
{/* TABS */}
|
||||||
|
|
||||||
<nav className="sticky top-0 z-20 border-b bg-white">
|
<nav className="border-b bg-white">
|
||||||
<div className="mx-auto p-4 py-2 md:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-6">
|
||||||
{/* CAMBIOS CLAVE:
|
<div className="flex justify-center gap-8">
|
||||||
1. overflow-x-auto: Permite scroll horizontal.
|
|
||||||
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
|
|
||||||
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
|
|
||||||
*/}
|
|
||||||
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-start">
|
|
||||||
{[
|
{[
|
||||||
{ label: 'Datos', to: '' },
|
{ label: 'Datos', to: '' },
|
||||||
{ label: 'Contenido', to: 'contenido' },
|
{ label: 'Contenido', to: 'contenido' },
|
||||||
@@ -405,12 +251,11 @@ function AsignaturaLayout() {
|
|||||||
}
|
}
|
||||||
from="/planes/$planId/asignaturas/$asignaturaId"
|
from="/planes/$planId/asignaturas/$asignaturaId"
|
||||||
params={{ planId, asignaturaId }}
|
params={{ planId, asignaturaId }}
|
||||||
className={cn(
|
className={`border-b-2 py-3 text-sm font-medium ${
|
||||||
'shrink-0 border-b-2 py-3 text-sm font-medium transition-colors',
|
|
||||||
isActive
|
isActive
|
||||||
? 'border-primary text-primary font-bold'
|
? 'border-blue-600 text-blue-600'
|
||||||
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
|
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||||
)}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -420,7 +265,7 @@ function AsignaturaLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mx-auto p-4 py-8 md:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import BarraBusqueda from '@/components/planes/BarraBusqueda'
|
|||||||
import Filtro from '@/components/planes/Filtro'
|
import Filtro from '@/components/planes/Filtro'
|
||||||
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
||||||
// Hooks y Utils (ajusta las rutas de importación)
|
// Hooks y Utils (ajusta las rutas de importación)
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
|
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
|
||||||
import { getIconByName } from '@/features/planes/utils/icon-utils'
|
import { getIconByName } from '@/features/planes/utils/icon-utils'
|
||||||
|
|
||||||
@@ -102,20 +101,13 @@ function RouteComponent() {
|
|||||||
setPage(0) // Resetear página al buscar
|
setPage(0) // Resetear página al buscar
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deshabilitar el botón 'Limpiar' si no hay filtros distintos al valor por defecto
|
|
||||||
const isClearDisabled =
|
|
||||||
cleanSearchTerm === '' &&
|
|
||||||
facultadSel === 'todas' &&
|
|
||||||
carreraSel === 'todas' &&
|
|
||||||
estadoSel === 'todos'
|
|
||||||
|
|
||||||
// Renderizado condicional básico
|
// Renderizado condicional básico
|
||||||
if (isError)
|
if (isError)
|
||||||
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-background min-h-screen w-full">
|
<main className="bg-background min-h-screen w-full">
|
||||||
<div className="mx-auto flex w-full flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||||
<div className="flex flex-col gap-4 lg:col-span-3">
|
<div className="flex flex-col gap-4 lg:col-span-3">
|
||||||
{/* Header y Botón Nuevo */}
|
{/* Header y Botón Nuevo */}
|
||||||
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
@@ -132,16 +124,16 @@ function RouteComponent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('planId')
|
console.log('planId')
|
||||||
|
|
||||||
navigate({ to: '/planes/nuevo', resetScroll: false })
|
navigate({ to: '/planes/nuevo', resetScroll: false })
|
||||||
}}
|
}}
|
||||||
className="shadow-md"
|
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||||
>
|
>
|
||||||
<Icons.Plus /> Nuevo plan de estudios
|
<Icons.Plus /> Nuevo plan de estudios
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barra de Filtros */}
|
{/* Barra de Filtros */}
|
||||||
@@ -189,15 +181,13 @@ function RouteComponent() {
|
|||||||
placeholder="Estado"
|
placeholder="Estado"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
|
||||||
onClick={resetFilters}
|
onClick={resetFilters}
|
||||||
disabled={isClearDisabled}
|
className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors"
|
||||||
className={`shadow-md`}
|
|
||||||
>
|
>
|
||||||
<Icons.X className="h-4 w-4" /> Limpiar
|
<Icons.X className="h-4 w-4" /> Limpiar
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,9 +212,9 @@ function RouteComponent() {
|
|||||||
// NOTA: El color del estado no viene en BD por defecto,
|
// NOTA: El color del estado no viene en BD por defecto,
|
||||||
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
|
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
|
||||||
// Aquí uso un fallback simple.
|
// Aquí uso un fallback simple.
|
||||||
const estadoColorHex = (estado as any)?.color as
|
const estadoColor = estado?.es_final
|
||||||
| string
|
? 'bg-emerald-600'
|
||||||
| undefined
|
: 'bg-amber-600'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlanEstudiosCard
|
<PlanEstudiosCard
|
||||||
@@ -235,8 +225,7 @@ function RouteComponent() {
|
|||||||
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
|
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
|
||||||
facultad={facultad?.nombre ?? 'Sin Facultad'}
|
facultad={facultad?.nombre ?? 'Sin Facultad'}
|
||||||
estado={estado?.etiqueta ?? 'Desconocido'}
|
estado={estado?.etiqueta ?? 'Desconocido'}
|
||||||
colorEstadoHex={estadoColorHex}
|
claseColorEstado={estadoColor}
|
||||||
claseColorEstado={!estadoColorHex ? 'bg-secondary' : ''}
|
|
||||||
colorFacultad={facultad?.color ?? '#000000'}
|
colorFacultad={facultad?.color ?? '#000000'}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate({
|
navigate({
|
||||||
|
|||||||
+12
-162
@@ -4,145 +4,18 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Sans';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Serif */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Indivisa Serif';
|
|
||||||
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
|
|
||||||
format('opentype');
|
|
||||||
font-weight: 900;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply m-0;
|
@apply m-0;
|
||||||
font-family: var(--font-sans);
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||||
|
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: var(--font-mono);
|
font-family:
|
||||||
}
|
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
|
|
||||||
strong,
|
|
||||||
b,
|
|
||||||
.font-bold {
|
|
||||||
font-family: 'Indivisa Sans', serif;
|
|
||||||
font-weight: 900;
|
|
||||||
/* Inter letter space */
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -178,9 +51,9 @@ b,
|
|||||||
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
||||||
--sidebar-border: oklch(0.9401 0 0);
|
--sidebar-border: oklch(0.9401 0 0);
|
||||||
--sidebar-ring: oklch(0 0 0);
|
--sidebar-ring: oklch(0 0 0);
|
||||||
--font-sans: 'Indivisa Sans', sans-serif;
|
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||||
--font-serif: 'Indivisa Serif', serif;
|
--font-serif: Lora, serif;
|
||||||
--font-mono: 'Indivisa Sans', monospace;
|
--font-mono: IBM Plex Mono, monospace;
|
||||||
--radius: 1.4rem;
|
--radius: 1.4rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
@@ -228,7 +101,7 @@ b,
|
|||||||
--chart-1: oklch(0.6686 0.1794 251.7436);
|
--chart-1: oklch(0.6686 0.1794 251.7436);
|
||||||
--chart-2: oklch(0.6342 0.2516 22.4415);
|
--chart-2: oklch(0.6342 0.2516 22.4415);
|
||||||
--chart-3: oklch(0.8718 0.1716 90.9505);
|
--chart-3: oklch(0.8718 0.1716 90.9505);
|
||||||
--chart-4: oklch(11.492% 0.00001 271.152);
|
--chart-4: oklch(0.4503 0.229 263.0881);
|
||||||
--chart-5: oklch(0.8322 0.146 185.9404);
|
--chart-5: oklch(0.8322 0.146 185.9404);
|
||||||
--sidebar: oklch(0.1564 0.0688 261.2771);
|
--sidebar: oklch(0.1564 0.0688 261.2771);
|
||||||
--sidebar-foreground: oklch(0.9551 0 0);
|
--sidebar-foreground: oklch(0.9551 0 0);
|
||||||
@@ -238,9 +111,9 @@ b,
|
|||||||
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
||||||
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
||||||
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
||||||
--font-sans: 'Indivisa Sans', sans-serif;
|
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||||
--font-serif: 'Indivisa Serif', serif;
|
--font-serif: Lora, serif;
|
||||||
--font-mono: 'Indivisa Sans', monospace;
|
--font-mono: IBM Plex Mono, monospace;
|
||||||
--radius: 1.4rem;
|
--radius: 1.4rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
@@ -335,26 +208,3 @@ body {
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================================================================
|
|
||||||
Utilidades locales (sin romper el design system)
|
|
||||||
|
|
||||||
Intención:
|
|
||||||
- Evitar estilos inline en JSX para assets puntuales del proyecto
|
|
||||||
===================================================================== */
|
|
||||||
|
|
||||||
.login-bg {
|
|
||||||
background-image: url('/fondo_login.png');
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
button:not(:disabled),
|
|
||||||
[role='button']:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled,
|
|
||||||
[role='button']:disabled {
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-2
@@ -31,7 +31,12 @@ export interface Carrera {
|
|||||||
facultadId: string
|
facultadId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LineaCurricular = Tables<'lineas_plan'>
|
export interface LineaCurricular {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
orden: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Asignatura {
|
export interface Asignatura {
|
||||||
id: string
|
id: string
|
||||||
@@ -45,7 +50,7 @@ export interface Asignatura {
|
|||||||
orden?: number
|
orden?: number
|
||||||
hd: number // <--- Añadir
|
hd: number // <--- Añadir
|
||||||
hi: number // <--- Añadir
|
hi: number // <--- Añadir
|
||||||
prerrequisito_asignatura_id: string | null
|
prerrequisitos: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
|
|||||||
+413
-468
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
|||||||
// Convierte HSL a Hexadecimal
|
|
||||||
function hslToHex(h: number, s: number, l: number): string {
|
|
||||||
l /= 100
|
|
||||||
const a = (s * Math.min(l, 1 - l)) / 100
|
|
||||||
const f = (n: number) => {
|
|
||||||
const k = (n + h / 30) % 12
|
|
||||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
|
||||||
return Math.round(255 * color)
|
|
||||||
.toString(16)
|
|
||||||
.padStart(2, '0')
|
|
||||||
}
|
|
||||||
return `#${f(0)}${f(8)}${f(4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Genera un color contrastante.
|
|
||||||
* @param prevHue El tono (0-360) del color anterior. Null si es el primero.
|
|
||||||
* @returns Objeto con el hex y el nuevo hue para guardar como referencia.
|
|
||||||
*/
|
|
||||||
export function generarColorContrastante(prevHue: number | null = null) {
|
|
||||||
let newHue: number
|
|
||||||
|
|
||||||
if (prevHue === null) {
|
|
||||||
// Primer color: completamente al azar
|
|
||||||
newHue = Math.floor(Math.random() * 360)
|
|
||||||
} else {
|
|
||||||
// Siguientes: Salto aleatorio entre 130° y 230° para forzar contraste
|
|
||||||
const salto = 130 + Math.floor(Math.random() * 100)
|
|
||||||
newHue = (prevHue + salto) % 360
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mantenemos saturación al 70% y luminosidad al 50% para colores vivos pero no fosforescentes
|
|
||||||
const hex = hslToHex(newHue, 70, 50)
|
|
||||||
|
|
||||||
return { hex, hue: newHue }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationFallback": {
|
|
||||||
"rewrite": "/index.html",
|
|
||||||
"exclude": [
|
|
||||||
"/assets/*",
|
|
||||||
"/*.css",
|
|
||||||
"/*.js",
|
|
||||||
"/*.ico",
|
|
||||||
"/*.png",
|
|
||||||
"/*.jpg",
|
|
||||||
"/*.svg"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user