diff --git a/apps/api/src/controllers/ticket.ts b/apps/api/src/controllers/ticket.ts index ebaa11ec7..c6289b020 100644 --- a/apps/api/src/controllers/ticket.ts +++ b/apps/api/src/controllers/ticket.ts @@ -443,20 +443,29 @@ export function ticketRoutes(fastify: FastifyInstance) { const { user, id }: any = request.body; if (token) { - const assigned = await prisma.user.update({ - where: { id: user }, - data: { - tickets: { - connect: { - id: id, + if (user) { + const assigned = await prisma.user.update({ + where: { id: user }, + data: { + tickets: { + connect: { + id: id, + }, }, }, - }, - }); + }); - const { email } = assigned; + const { email } = assigned; - await sendAssignedEmail(email); + await sendAssignedEmail(email); + } else { + await prisma.ticket.update({ + where: { id: id }, + data: { + userId: null, + }, + }); + } reply.send({ success: true, diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index 9f3a67cf5..b19b179f8 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -8,14 +8,12 @@ import { emailQueueRoutes } from "./controllers/queue"; import { objectStoreRoutes } from "./controllers/storage"; import { ticketRoutes } from "./controllers/ticket"; import { timeTrackingRoutes } from "./controllers/time"; -import { todoRoutes } from "./controllers/todos"; import { userRoutes } from "./controllers/users"; import { webhookRoutes } from "./controllers/webhooks"; export function registerRoutes(fastify: FastifyInstance) { authRoutes(fastify); emailQueueRoutes(fastify); - todoRoutes(fastify); dataRoutes(fastify); ticketRoutes(fastify); userRoutes(fastify); diff --git a/apps/client/@/shadcn/ui/context-menu.tsx b/apps/client/@/shadcn/ui/context-menu.tsx new file mode 100644 index 000000000..f0695bac4 --- /dev/null +++ b/apps/client/@/shadcn/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/shadcn/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/apps/client/components/TicketDetails/index.tsx b/apps/client/components/TicketDetails/index.tsx index 05f9bbbf5..19406b717 100644 --- a/apps/client/components/TicketDetails/index.tsx +++ b/apps/client/components/TicketDetails/index.tsx @@ -154,7 +154,6 @@ export default function Ticket() { .then((res) => res.json()) .then(() => { setEdit(false); - // refetch(); }); } diff --git a/apps/client/package.json b/apps/client/package.json index 093c14a2c..f9bcfe581 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", diff --git a/apps/client/pages/issues/index.tsx b/apps/client/pages/issues/index.tsx index bad16cc26..a4872773a 100644 --- a/apps/client/pages/issues/index.tsx +++ b/apps/client/pages/issues/index.tsx @@ -1,16 +1,25 @@ import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import Loader from "react-spinners/ClipLoader"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; -import { ContextMenu } from "@radix-ui/themes"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, +} from "@/shadcn/ui/context-menu"; import { getCookie } from "cookies-next"; import moment from "moment"; import Link from "next/link"; import { useQuery } from "react-query"; import { useUser } from "../../store/session"; import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; -import { CheckIcon, Filter, PlusCircle, X } from "lucide-react"; +import { CheckIcon, Filter, X } from "lucide-react"; import { Button } from "@/shadcn/ui/button"; import { Command, @@ -22,6 +31,7 @@ import { CommandSeparator, } from "@/shadcn/ui/command"; import { cn } from "@/shadcn/lib/utils"; +import { toast } from "@/shadcn/hooks/use-toast"; async function getUserTickets(token: any) { const res = await fetch(`/api/v1/tickets/all`, { @@ -59,11 +69,11 @@ export default function Tickets() { const { t } = useTranslation("peppermint"); const token = getCookie("session"); - const { data, status, error } = useQuery( + const { data, status, error, refetch } = useQuery( "allusertickets", () => getUserTickets(token), { - refetchInterval: 1000, + refetchInterval: 5000, } ); @@ -77,6 +87,7 @@ export default function Tickets() { const [selectedPriorities, setSelectedPriorities] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); const [selectedAssignees, setSelectedAssignees] = useState([]); + const [users, setUsers] = useState([]); const handlePriorityToggle = (priority: string) => { setSelectedPriorities((prev) => @@ -145,6 +156,115 @@ export default function Tickets() { ); }, [data?.tickets, filterSearch]); + async function fetchUsers() { + await fetch(`/api/v1/users/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }) + .then((res) => res.json()) + .then((res) => { + if (res) { + setUsers(res.users); + } + }); + } + + async function updateTicketStatus(e: any, ticket: any) { + await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id, status: !ticket.isComplete }), + }) + .then((res) => res.json()) + .then(() => { + toast({ + title: ticket.isComplete ? "Issue re-opened" : "Issue closed", + description: "The status of the issue has been updated.", + duration: 3000, + }); + refetch(); + }); + } + + // Add these new functions + async function updateTicketAssignee(ticketId: string, user: any) { + try { + const response = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: user ? user.id : undefined, + id: ticketId, + }), + }); + + if (!response.ok) throw new Error("Failed to update assignee"); + + toast({ + title: "Assignee updated", + description: `Transferred issue successfully`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update assignee", + variant: "destructive", + duration: 3000, + }); + } + } + + async function updateTicketPriority(ticket: any, priority: string) { + try { + const response = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: ticket.id, + detail: ticket.detail, + note: ticket.note, + title: ticket.title, + priority: priority, + status: ticket.status, + }), + }).then((res) => res.json()); + + if (!response.success) throw new Error("Failed to update priority"); + + toast({ + title: "Priority updated", + description: `Ticket priority set to ${priority}`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update priority", + variant: "destructive", + duration: 3000, + }); + } + } + + useEffect(() => { + fetchUsers(); + }, []); + return (
{status === "loading" && ( @@ -390,9 +510,9 @@ export default function Tickets() { } return ( - - - + + +
@@ -460,47 +580,147 @@ export default function Tickets() {
-
- - {/* Edit */} - {/* - Status - - - - Assigned To - - - Priortiy - - - Label - */} - - {/* - More - - - Move to project… - - Move to folder… - - - Advanced options… - - - */} - - {/* - Share - Add to favorites - - - Delete - */} - -
- + + + + updateTicketStatus(e, ticket)} + > + {ticket.isComplete ? "Re-open Issue" : "Close Issue"} + + + + + Assign To + + + + + + updateTicketAssignee(ticket.id, undefined) + } + > +
+ +
+ Unassigned +
+ {users?.map((user) => ( + + updateTicketAssignee(ticket.id, user) + } + > +
+ +
+ {user.name} +
+ ))} +
+
+
+
+
+ + + + Change Priority + + + + + + {filteredPriorities.map((priority) => ( + + updateTicketPriority(ticket, priority) + } + > +
+ +
+ + {priority} + +
+ ))} +
+
+
+
+
+ + + + { + e.preventDefault(); + toast({ + title: "Link copied to clipboard", + description: + "You can now share the link with others.", + duration: 3000, + }); + navigator.clipboard.writeText( + `${window.location.origin}/issue/${ticket.id}` + ); + }} + > + Share Link + + + + + { + e.preventDefault(); + if ( + confirm( + "Are you sure you want to delete this ticket?" + ) + ) { + fetch(`/api/v1/ticket/delete`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id }), + }); + } + }} + > + Delete Ticket + +
+ ); }) ) : ( diff --git a/yarn.lock b/yarn.lock index 89fa73266..4dcd78485 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3024,7 +3024,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-context-menu@npm:^2.1.5": +"@radix-ui/react-context-menu@npm:^2.1.5, @radix-ui/react-context-menu@npm:^2.2.2": version: 2.2.2 resolution: "@radix-ui/react-context-menu@npm:2.2.2" dependencies: @@ -6787,6 +6787,7 @@ __metadata: "@radix-ui/react-alert-dialog": "npm:^1.1.2" "@radix-ui/react-avatar": "npm:^1.1.1" "@radix-ui/react-collapsible": "npm:^1.1.1" + "@radix-ui/react-context-menu": "npm:^2.2.2" "@radix-ui/react-dialog": "npm:^1.1.2" "@radix-ui/react-dropdown-menu": "npm:^2.1.2" "@radix-ui/react-label": "npm:^2.1.0"