diff --git a/client/components/tickets/ticket-complete-modal.tsx b/client/components/tickets/ticket-complete-modal.tsx new file mode 100644 index 0000000..bbed5b7 --- /dev/null +++ b/client/components/tickets/ticket-complete-modal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { BookOpen, CheckCircle } from "lucide-react"; +import { FormEvent, useState } from "react"; + +import Button from "../ui/button"; +import LabeledIcon from "../ui/labeled-icon"; +import Modal from "../ui/modal"; + +export interface TicketCompleteModalProps { + isOpen: boolean; + ticketTitle: string; + existingDocumentation?: string; + onClose: () => void; + onComplete: (documentation: string) => void; + isLoading?: boolean; +} + +export default function TicketCompleteModal({ + isOpen, + ticketTitle, + existingDocumentation = "", + onClose, + onComplete, + isLoading = false, +}: TicketCompleteModalProps) { + const [documentation, setDocumentation] = useState(existingDocumentation); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onComplete(documentation); + }; + + const handleClose = () => { + setDocumentation(existingDocumentation); // Reset to original value + onClose(); + }; + + return ( + + + + + + + Complete Ticket + + + + Ticket: {ticketTitle} + + + Before marking this ticket as complete, please document how you resolved it. + This helps other team members learn from your solution. + + + + + + + } label="Resolution Documentation" /> + + setDocumentation(e.target.value)} + placeholder="Describe how you resolved this ticket: • What was the root cause? • What steps did you take? • What solution worked? • Any preventive measures for the future?" + className="w-full p-3 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300 rounded-md resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + rows={8} + required + /> + + Documentation is required to complete the ticket + + + + + + Cancel + + + {isLoading ? ( + + + Completing... + + ) : ( + + + Complete Ticket + + )} + + + + + + ); +} \ No newline at end of file diff --git a/client/components/tickets/ticket-detail.tsx b/client/components/tickets/ticket-detail.tsx index 5f40eb2..8c6b229 100644 --- a/client/components/tickets/ticket-detail.tsx +++ b/client/components/tickets/ticket-detail.tsx @@ -1,11 +1,12 @@ "use client"; import Link from "next/link"; -import { Building, Calendar, PencilLine, SquareArrowUpRight, Tag, User } from "lucide-react"; +import { Building, Calendar, FileText, PencilLine, SquareArrowUpRight, Tag, User, BookOpen } from "lucide-react"; import { MouseEvent, useEffect, useState } from "react"; import TicketAssignedTo from "./ticket-assigned-to"; import TicketEdit from "./ticket-edit"; +import TicketCompleteModal from "./ticket-complete-modal"; import { useTicket, useUpdateTicket } from "@/lib/hooks/queries/use-tickets"; import { Status, Statuses } from "@/lib/types/ticket"; import Button from "../ui/button"; @@ -23,6 +24,8 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe const { mutate: updateTicket } = useUpdateTicket(); const [status, setStatus] = useState("Active"); const [isEditing, setIsEditing] = useState(false); + const [isCompletionModalOpen, setIsCompletionModalOpen] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); useEffect(() => { if (ticket) { @@ -58,7 +61,7 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe onSave={(updates) => { setIsEditing(false); updateTicket({ id: ticketId, updates }); - setTimeout(onUpdate, 300); + onUpdate(); // Call immediately since cache is updated }} /> ); @@ -91,7 +94,35 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe setStatus(updatedStatus); updateTicket({ id: ticketId, updates: { status: updatedStatus } }); - setTimeout(onUpdate, 300); + onUpdate(); // Call immediately since cache is updated + }; + + const handleCompleteTicket = () => { + setIsCompletionModalOpen(true); + }; + + const handleCompleteWithDocumentation = (documentation: string) => { + setIsCompleting(true); + + // Use updateTicket instead of completeTicket to set both status and documentation + updateTicket({ + id: ticketId, + updates: { + status: "Closed", + documentation: documentation + } + }, { + onSuccess: (updatedTicket) => { + setStatus("Closed"); + setIsCompletionModalOpen(false); + setIsCompleting(false); + onUpdate(); // Call immediately since cache is updated + }, + onError: (error) => { + console.error('Error updating ticket:', error); + setIsCompleting(false); + }, + }); }; return ( @@ -103,12 +134,22 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe > <- - setIsEditing(true)} - > - } label="Edit" /> - + + {ticket.status !== "Closed" && ( + + Complete Ticket + + )} + setIsEditing(true)} + > + } label="Edit" /> + + TK {ticket.id} @@ -122,7 +163,33 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe {ticket.title} - {ticket.description} + + {/* Description Section */} + + + } label="Description" /> + + + + {ticket.description || "No description provided."} + + + + + {/* Documentation Section */} + + + } label="Resolution Documentation" /> + + + + {ticket.documentation && ticket.documentation.trim() + ? ticket.documentation + : "No resolution documentation provided yet."} + + + + @@ -167,6 +234,16 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe + + {/* Completion Modal */} + setIsCompletionModalOpen(false)} + onComplete={handleCompleteWithDocumentation} + isLoading={isCompleting} + /> ); } diff --git a/client/components/tickets/ticket-edit.tsx b/client/components/tickets/ticket-edit.tsx index 43c2a74..5050f8d 100644 --- a/client/components/tickets/ticket-edit.tsx +++ b/client/components/tickets/ticket-edit.tsx @@ -1,7 +1,7 @@ "use client"; -import { Building, Tag, User } from "lucide-react"; -import { FormEvent, useState } from "react"; +import { BookOpen, Building, Tag, User } from "lucide-react"; +import { FormEvent, useState, useEffect } from "react"; import { useTicket, useUpdateTicket } from "@/lib/hooks/queries/use-tickets"; import { useToast } from "@/lib/hooks/use-toast"; @@ -20,9 +20,16 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro const { data: ticket } = useTicket(ticketId); const { mutate: updateTicket } = useUpdateTicket(); - const [formData, setFormData] = useState>(ticket || {}); + const [formData, setFormData] = useState>({}); const [isSaving, setIsSaving] = useState(false); + // Update form data when ticket data loads/changes + useEffect(() => { + if (ticket) { + setFormData(ticket); + } + }, [ticket]); + if (!ticket) { let layout = <>>; @@ -95,7 +102,7 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro TK {ticket.id} Status - + {Statuses.map((status) => ( {status} @@ -107,7 +114,7 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro Priority Description + + + } label="Resolution Documentation" /> + + + @@ -153,7 +173,7 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro } label="Site" /> - + {Sites.map((site) => ( {site} @@ -175,7 +195,7 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro } label="Category" /> - + {Categories.map((category) => ( {category} diff --git a/client/lib/api/tickets.ts b/client/lib/api/tickets.ts index 49afa46..10f24ae 100644 --- a/client/lib/api/tickets.ts +++ b/client/lib/api/tickets.ts @@ -20,3 +20,8 @@ export async function updateTicket(id: string, updates: Partial) { const { data: updatedTicket } = await server.put(`/tickets/${id}`, updates); return updatedTicket; } + +export async function completeTicket(id: string) { + const { data: completedTicket } = await server.post(`/tickets/${id}/complete`); + return completedTicket; +} diff --git a/client/lib/hooks/queries/use-tickets.ts b/client/lib/hooks/queries/use-tickets.ts index 26ef4b2..9761e79 100644 --- a/client/lib/hooks/queries/use-tickets.ts +++ b/client/lib/hooks/queries/use-tickets.ts @@ -1,4 +1,4 @@ -import { createTicket, getTicket, getTickets, updateTicket } from "@/lib/api/tickets"; +import { completeTicket, createTicket, getTicket, getTickets, updateTicket } from "@/lib/api/tickets"; import Ticket from "@/lib/types/ticket"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -39,6 +39,20 @@ export const useUpdateTicket = () => { return useMutation }>({ mutationFn: ({ id, updates }) => updateTicket(id, updates), + onSuccess: (updatedTicket, variables) => { + // Update the individual ticket cache with the fresh data + client.setQueryData(["tickets", variables.id], updatedTicket); + // Invalidate the tickets list to refresh it as well + client.invalidateQueries({ queryKey: ["tickets"], exact: false }); + }, + }); +}; + +export const useCompleteTicket = () => { + const client = useQueryClient(); + + return useMutation({ + mutationFn: (id) => completeTicket(id), onSuccess: () => client.invalidateQueries({ queryKey: ["tickets"] }), }); }; diff --git a/client/lib/types/ticket.ts b/client/lib/types/ticket.ts index af6456e..3e3e505 100644 --- a/client/lib/types/ticket.ts +++ b/client/lib/types/ticket.ts @@ -2,6 +2,7 @@ export default interface Ticket { id: string; title: string; description: string; + documentation?: string; site: Site; category: Category; assignedTo?: string; diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 2980eb5..fd8b848 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -43,6 +43,7 @@ func (h *TicketHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/tickets", h.handleGetTickets) mux.HandleFunc("GET /api/v1/tickets/{id}", h.handleGetTicket) mux.HandleFunc("PUT /api/v1/tickets/{id}", h.handleUpdateTicket) + mux.HandleFunc("POST /api/v1/tickets/{id}/complete", h.handleCompleteTicket) mux.HandleFunc("DELETE /api/v1/tickets/{id}", h.handleDeleteTicket) } @@ -187,6 +188,41 @@ func (h *TicketHandler) handleUpdateTicket(w http.ResponseWriter, r *http.Reques encodeJSON(h, w, ticket) } +// handleCompleteTicket handles marking a ticket as completed (closed) +func (h *TicketHandler) handleCompleteTicket(w http.ResponseWriter, r *http.Request) { + var ( + ticketId = r.PathValue("id") + updates = map[string]any{ + "status": "Closed", + "updatedAt": time.Now(), + } + sugar = h.logger.Sugar() + ) + + ctx, cancel := context.WithTimeout(context.Background(), _databaseTimeoutPolicy) + defer cancel() + + ticket, err := h.store.UpdateTicket(ctx, ticketId, updates) + if err != nil { + switch { + case errors.Is(err, storage.ErrTicketNotFound): + sugar.Debug(err) + http.Error(w, err.Error(), http.StatusNotFound) + + return + default: + sugar.Error(err) + http.Error(w, errInternal.Error(), http.StatusInternalServerError) + + return + } + } + + w.WriteHeader(http.StatusOK) + + encodeJSON(h, w, ticket) +} + // handleDeleteTicket handles deleting a ticket func (h *TicketHandler) handleDeleteTicket(w http.ResponseWriter, r *http.Request) { var ( diff --git a/server/internal/models/ticket.go b/server/internal/models/ticket.go index 0b5a018..7c1e972 100644 --- a/server/internal/models/ticket.go +++ b/server/internal/models/ticket.go @@ -9,17 +9,18 @@ import ( // Ticket represents an IT ticket with associated metadata type Ticket struct { - ID string `json:"id" bson:"_id"` - Title string `json:"title"` - Description string `json:"description"` - Site string `json:"site"` - Category string `json:"category"` - AssignedTo string `json:"assignedTo"` - CreatedBy string `json:"createdBy"` - Priority int `json:"priority"` - Status string `json:"status"` - CreatedOn time.Time `json:"createdOn"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" bson:"_id"` + Title string `json:"title" bson:"title"` + Description string `json:"description" bson:"description"` + Documentation string `json:"documentation" bson:"documentation"` + Site string `json:"site" bson:"site"` + Category string `json:"category" bson:"category"` + AssignedTo string `json:"assignedTo" bson:"assignedTo"` + CreatedBy string `json:"createdBy" bson:"createdBy"` + Priority int `json:"priority" bson:"priority"` + Status string `json:"status" bson:"status"` + CreatedOn time.Time `json:"createdOn" bson:"createdOn"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` } // UnmarshalBSON provides a custom unmarshal implementation for Ticket, enabling @@ -28,17 +29,18 @@ func (t *Ticket) UnmarshalBSON(data []byte) error { var ( buffer bytes.Buffer result struct { - ID string `json:"id" bson:"_id"` - Title string `json:"title"` - Description string `json:"description"` - Site string `json:"site"` - Category string `json:"category"` - AssignedTo string `json:"assignedTo"` - CreatedBy string `json:"createdBy"` - Priority int `json:"priority"` - Status string `json:"status"` - CreatedOn time.Time `json:"createdOn"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" bson:"_id"` + Title string `json:"title" bson:"title"` + Description string `json:"description" bson:"description"` + Documentation string `json:"documentation" bson:"documentation"` + Site string `json:"site" bson:"site"` + Category string `json:"category" bson:"category"` + AssignedTo string `json:"assignedTo" bson:"assignedTo"` + CreatedBy string `json:"createdBy" bson:"createdBy"` + Priority int `json:"priority" bson:"priority"` + Status string `json:"status" bson:"status"` + CreatedOn time.Time `json:"createdOn" bson:"createdOn"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` } ) _, _ = buffer.Write(data) @@ -51,17 +53,18 @@ func (t *Ticket) UnmarshalBSON(data []byte) error { } *t = Ticket{ - ID: result.ID, - Title: result.Title, - Description: result.Description, - Site: result.Site, - Category: result.Category, - AssignedTo: result.AssignedTo, - CreatedBy: result.CreatedBy, - Priority: result.Priority, - Status: result.Status, - CreatedOn: result.CreatedOn, - UpdatedAt: result.UpdatedAt, + ID: result.ID, + Title: result.Title, + Description: result.Description, + Documentation: result.Documentation, + Site: result.Site, + Category: result.Category, + AssignedTo: result.AssignedTo, + CreatedBy: result.CreatedBy, + Priority: result.Priority, + Status: result.Status, + CreatedOn: result.CreatedOn, + UpdatedAt: result.UpdatedAt, } return nil diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go index 42bd96e..d8de2fb 100644 --- a/server/internal/storage/storage.go +++ b/server/internal/storage/storage.go @@ -54,6 +54,7 @@ func (s *TicketStore) CreateTicket(ctx context.Context, ticket models.Ticket) (i doc = bson.D{ {Key: "title", Value: ticket.Title}, {Key: "description", Value: ticket.Description}, + {Key: "documentation", Value: ticket.Documentation}, {Key: "site", Value: ticket.Site}, {Key: "category", Value: ticket.Category}, {Key: "assignedTo", Value: ticket.AssignedTo}, @@ -148,6 +149,10 @@ func (s *TicketStore) UpdateTicket(ctx context.Context, id string, updates map[s updatesDoc = append(updatesDoc, bson.E{Key: "description", Value: description}) } + if documentation, ok := updates["documentation"].(string); ok { + updatesDoc = append(updatesDoc, bson.E{Key: "documentation", Value: documentation}) + } + if site, ok := updates["site"].(string); ok { updatesDoc = append(updatesDoc, bson.E{Key: "site", Value: site}) }
+ Ticket: {ticketTitle} +
+ Before marking this ticket as complete, please document how you resolved it. + This helps other team members learn from your solution. +
+ Documentation is required to complete the ticket +
TK {ticket.id}
{ticket.title}
{ticket.description}
+ {ticket.description || "No description provided."} +
+ {ticket.documentation && ticket.documentation.trim() + ? ticket.documentation + : "No resolution documentation provided yet."} +