diff --git a/bun.lock b/bun.lock index 18b91f7..502ee82 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", @@ -221,6 +222,8 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], diff --git a/package.json b/package.json index 37ce939..d5b89ef 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", diff --git a/src/components/carreras/DeleteCarreras.tsx b/src/components/carreras/DeleteCarreras.tsx new file mode 100644 index 0000000..cf54e14 --- /dev/null +++ b/src/components/carreras/DeleteCarreras.tsx @@ -0,0 +1,54 @@ +import { supabase } from "@/auth/supabase"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import { useState } from "react"; +import { Button } from "../ui/button"; +import { Trash } from "lucide-react"; +import { carrerasKeys } from "@/routes/_authenticated/carreras"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"; + +export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const qc = useQueryClient() + const router = useRouter() + + async function handleDelete() { + setLoading(true) + try { + const { error } = await supabase.from("carreras").delete().eq("id", carreraId) + if (error) throw error + setOpen(false) + if (onDeleted) onDeleted() + await qc.invalidateQueries({ queryKey: carrerasKeys.root }) + router.invalidate() + } catch (e: any) { + alert(e?.message || "Error al eliminar la carrera") + } finally { + setLoading(false) + } + } + + const dialog = ( + + + + ¿Eliminar carrera? + + Esta acción no se puede deshacer. ¿Seguro que quieres eliminar esta carrera? + + + + + + + + + ) + + return { open, setOpen, dialog } +} \ No newline at end of file diff --git a/src/components/planes/DeletePlan.tsx b/src/components/planes/DeletePlan.tsx new file mode 100644 index 0000000..b561a02 --- /dev/null +++ b/src/components/planes/DeletePlan.tsx @@ -0,0 +1,41 @@ +import { supabase } from "@/auth/supabase"; +import { useRouter } from "@tanstack/react-router"; +import { useState } from "react"; +import { Button } from "../ui/button"; + +export function DeletePlanButton({ planId, onDeleted }: { planId: string; onDeleted?: () => void }) { + const [confirm, setConfirm] = useState(false) + const [loading, setLoading] = useState(false) + const router = useRouter() + + async function handleDelete() { + setLoading(true) + try { + const { error, status, statusText} = await supabase.from("plan_estudios").delete().eq("id", planId) + console.log({status, statusText}); + + + if (error) throw error + setConfirm(false) + if (onDeleted) onDeleted() + router.navigate({ to: "/planes", search: { plan: '' } }) + } catch (e: any) { + alert(e?.message || "Error al eliminar el plan") + } finally { + setLoading(false) + } + } + + return confirm ? ( +
+ + +
+ ) : ( + + ) +} \ No newline at end of file diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..08ec121 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/routes/_authenticated/carreras.tsx b/src/routes/_authenticated/carreras.tsx index 8bb6cd5..c07c8b6 100644 --- a/src/routes/_authenticated/carreras.tsx +++ b/src/routes/_authenticated/carreras.tsx @@ -14,6 +14,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion" import { Switch } from "@/components/ui/switch" +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu" +import { useDeleteCarreraDialog } from "@/components/carreras/DeleteCarreras" /* -------------------- Tipos -------------------- */ type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null } @@ -27,7 +29,7 @@ export type CarreraRow = { } /* -------------------- Query Keys & Fetchers -------------------- */ -const carrerasKeys = { +export const carrerasKeys = { root: ["carreras"] as const, list: () => [...carrerasKeys.root, "list"] as const, } @@ -115,11 +117,10 @@ const tint = (hex?: string | null, a = 0.18) => { } const StatusPill = ({ active }: { active: boolean }) => ( {active ? "Activa" : "Inactiva"} @@ -212,36 +213,47 @@ function RouteComponent() { const border = tint(fac?.color, 0.28) const chip = tint(fac?.color, 0.1) const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2 + const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(c.id, async () => { + await qc.invalidateQueries({ queryKey: carrerasKeys.root }) + router.invalidate() + }) return ( -
-
-
- - - -
-
{c.nombre}
-
{fac?.nombre ?? "—"} · {c.semestres} semestres
-
-
+ + openContextMenu(e)}> +
+
+
+ + + +
+
{c.nombre}
+
{fac?.nombre ?? "—"} · {c.semestres} semestres
+
+
-
- -
- - +
+ +
-
-
-
+
+ + + setDetail(c)}> + Ver + + setEditCarrera(c)}> + Editar + + setDeleteOpen(true)}> + Eliminar + + + {deleteDialog} + ) })} @@ -287,6 +299,22 @@ function RouteComponent() { ) } +function openContextMenu(e: React.MouseEvent) { + e.preventDefault() + e.stopPropagation() + // Simulate right click by opening context menu + const trigger = e.currentTarget + if (!(trigger instanceof HTMLElement)) return + const event = new window.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view: window, + clientX: e.clientX, + clientY: e.clientY, + }) + trigger.dispatchEvent(event) +} + /* -------------------- Form crear/editar -------------------- */ function CarreraFormDialog({ open, diff --git a/src/routes/_authenticated/plan/$planId.tsx b/src/routes/_authenticated/plan/$planId.tsx index efeda6f..9d9e4be 100644 --- a/src/routes/_authenticated/plan/$planId.tsx +++ b/src/routes/_authenticated/plan/$planId.tsx @@ -18,6 +18,7 @@ import confetti from "canvas-confetti" import { Textarea } from "@/components/ui/textarea" import { AuroraButton } from "@/components/effect/aurora-button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { DeletePlanButton } from "@/components/planes/DeletePlan" type LoaderData = { planId: string } @@ -94,6 +95,7 @@ function RouteComponent() {
+