tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index d88a778..71544f2 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -2,6 +2,7 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanstackDevtools } from '@tanstack/react-devtools'
import type { SupabaseAuthState } from '@/auth/supabase'
+import { ThemeProvider } from '@/components/theme-provider'
interface AuthContext {
auth: SupabaseAuthState
@@ -10,18 +11,21 @@ interface AuthContext {
export const Route = createRootRouteWithContext()({
component: () => (
<>
-
- ,
- },
- ]}
- />
+
+
+
+ ,
+ },
+ ]}
+ />
+
>
),
})
diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx
index 1a48cd4..2448249 100644
--- a/src/routes/_authenticated.tsx
+++ b/src/routes/_authenticated.tsx
@@ -1,9 +1,167 @@
-import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createFileRoute, Outlet, redirect, Link } from "@tanstack/react-router"
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
+import { ModeToggle } from "@/components/mode-toggle"
+import {
+ Menu,
+ Bell,
+ Search as SearchIcon,
+ LayoutDashboard,
+ GraduationCap,
+ FileText,
+ Settings,
+ LogOut,
+} from "lucide-react"
+import { useSupabaseAuth } from "@/auth/supabase"
-export const Route = createFileRoute('/_authenticated')({
+export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
- throw redirect({ to: '/login', search: { redirect: location.href } })
+ throw redirect({ to: "/login", search: { redirect: location.href } })
}
},
-})
\ No newline at end of file
+ component: Layout,
+})
+
+const nav = [
+ { to: "/planes", label: "Planes", icon: GraduationCap },
+ { to: "/materias", label: "Materias", icon: FileText },
+ { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
+ { to: "/ajustes", label: "Ajustes", icon: Settings },
+]
+
+function Layout() {
+ const { auth } = Route.useRouteContext()
+ const [query, setQuery] = useState("")
+
+ return (
+
+ {/* Top bar */}
+
+
+ {/* Mobile menu */}
+
+
+
+
+
+ { }} />
+
+
+
+ {/* Brand */}
+
+ U
+ La Salle · Ingeniería
+
+
+ {/* Search */}
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Buscar…"
+ className="w-64 pl-9"
+ />
+
+
+
+
+
+
+
+
+ {/* Shell */}
+
+ {/* Sidebar (desktop) */}
+
+
+ {/* Main content */}
+
+
+
+
+
+ )
+}
+
+function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
+ return (
+
+
+ U
+ La Salle · Ingeniería
+
+
+
+
+
+
+ © {new Date().getFullYear()} La Salle
+
+ )
+}
+
+function UserMenu({ user }: { user: { name?: string; avatar?: string | null } }) {
+ const auth = useSupabaseAuth()
+ return (
+
+
+
+
+
+ {user.name}
+
+
+ Perfil
+
+
+ Ajustes
+
+
+
+ Cerrar sesión
+
+
+
+ )
+}
diff --git a/src/routes/_authenticated/planes.tsx b/src/routes/_authenticated/planes.tsx
index 933fa55..7c29e45 100644
--- a/src/routes/_authenticated/planes.tsx
+++ b/src/routes/_authenticated/planes.tsx
@@ -1,22 +1,131 @@
-import { useSupabaseAuth } from '@/auth/supabase'
-import { createFileRoute } from '@tanstack/react-router'
+import { createFileRoute, useRouter } from "@tanstack/react-router"
+import { useMemo, useState } from "react"
+import { supabase, useSupabaseAuth } from "@/auth/supabase"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Plus, RefreshCcw } from "lucide-react"
-export const Route = createFileRoute('/_authenticated/planes')({
+// --- Tipo correcto según tu esquema ---
+export type PlanDeEstudios = {
+ id: string
+ nombre: string
+ nivel: string | null
+ objetivo_general: string | null
+ perfil_ingreso: string | null
+ perfil_egreso: string | null
+ duracion: string | null
+ total_creditos: number | null
+ competencias_genericas: string | null
+ competencias_especificas: string | null
+ sistema_evaluacion: string | null
+ indicadores_desempeno: string | null
+ estado: string | null
+ fecha_creacion: string | null // timestamp with time zone → string ISO
+ pertinencia: string | null
+ prompt: string | null
+ carrera_id: string | null // uuid
+}
+
+export const Route = createFileRoute("/_authenticated/planes")({
component: RouteComponent,
+ loader: async () => {
+ const { data, error } = await supabase
+ .from("plan_estudios")
+ .select("*")
+ .order("fecha_creacion", { ascending: false })
+ .limit(100)
+
+ if (error) throw new Error(error.message)
+ return data as PlanDeEstudios[]
+ }
})
function RouteComponent() {
const auth = useSupabaseAuth()
- console.log(auth.user);
+ const [q, setQ] = useState("")
+ const data = Route.useLoaderData()
+ const router = useRouter()
- return
- Hello "/_authenticated/planes"!
-
-
+ const filtered = useMemo(() => {
+ const term = q.trim().toLowerCase()
+ if (!term || !data) return data
+ return data.filter((p) =>
+ [p.nombre, p.nivel, p.estado]
+ .filter(Boolean)
+ .some((v) => String(v).toLowerCase().includes(term))
+ )
+ }, [q, data])
+
+ return (
+
+
+
+ Planes de estudio
+
+
+ setQ(e.target.value)} placeholder="Buscar por nombre, nivel o estado…" />
+
+
+
+
+
+
+
+
+
+
+ Nombre
+ Nivel
+ Créditos
+ Duración
+ Estado
+ Creado
+
+
+
+ {filtered?.map((p) => (
+
+ {p.nombre}
+ {p.nivel ?? "—"}
+ {p.total_creditos ?? "—"}
+ {p.duracion ?? "—"}
+
+ {p.estado ? (
+
+ {p.estado}
+
+ ) : (
+ "—"
+ )}
+
+
+ {p.fecha_creacion ? new Date(p.fecha_creacion).toLocaleDateString() : "—"}
+
+
+ ))}
+ {!filtered?.length && (
+
+
+ Sin resultados
+
+
+ )}
+
+
+
+
+
+
+
+ Logueado como: {auth.user?.email}
+
+
+ )
}
diff --git a/src/routes/login.tsx b/src/routes/login.tsx
index 86f754d..fb4c72f 100644
--- a/src/routes/login.tsx
+++ b/src/routes/login.tsx
@@ -1,92 +1,149 @@
-import { createFileRoute, redirect } from '@tanstack/react-router'
-import { useState } from 'react'
+import { createFileRoute, redirect } from "@tanstack/react-router"
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+import { Mail, Lock, Eye, EyeOff, Loader2, Shield } from "lucide-react"
+import { useTheme } from "@/components/theme-provider"
-export const Route = createFileRoute('/login')({
- validateSearch: (search) => ({
- redirect: (search.redirect as string) || '/planes',
- }),
- beforeLoad: ({ context, search }) => {
- if (context.auth.isAuthenticated) {
- throw redirect({ to: search.redirect })
- }
- },
- component: LoginComponent,
+export const Route = createFileRoute("/login")({
+ validateSearch: (search) => ({
+ redirect: (search.redirect as string) || "/planes",
+ }),
+ beforeLoad: ({ context, search }) => {
+ if (context.auth.isAuthenticated) {
+ throw redirect({ to: search.redirect })
+ }
+ },
+ component: LoginComponent,
})
function LoginComponent() {
- const { auth } = Route.useRouteContext()
- const { redirect } = Route.useSearch()
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState('')
+ const { auth } = Route.useRouteContext()
+ const { redirect } = Route.useSearch()
+ const [email, setEmail] = useState("")
+ const [password, setPassword] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState("")
+ const [showPassword, setShowPassword] = useState(false)
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setIsLoading(true)
+ setError("")
- try {
- await auth.login(email, password)
- // Supabase auth will automatically update context
- window.location.href = redirect
- } catch (err: any) {
- setError(err.message || 'Login failed')
- } finally {
- setIsLoading(false)
- }
+ try {
+ await auth.login(email, password)
+ window.location.href = redirect
+ } catch (err: any) {
+ setError(err.message || "No fue posible iniciar sesión")
+ } finally {
+ setIsLoading(false)
}
+ }
+
- return (
-
+ )
}
\ No newline at end of file
diff --git a/src/styles.css b/src/styles.css
index 7fc60e3..b3ea50c 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,14 +1,120 @@
+@import "tailwindcss";
+@import "tw-animate-css";
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
- "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
}
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
- monospace;
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(27.5% 0.13488 262.73);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(53.662% 0.21722 24.323);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 7d5170a..ecffc65 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,10 +2,11 @@ import { defineConfig } from 'vite'
import viteReact from '@vitejs/plugin-react'
import tanstackRouter from '@tanstack/router-plugin/vite'
import { resolve } from 'node:path'
+import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [tanstackRouter({ autoCodeSplitting: true }), viteReact()],
+ plugins: [tanstackRouter({ autoCodeSplitting: true }), viteReact(), tailwindcss()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
|