From 7822219ba6dd1f98c36142c4b672f624f55ba70b Mon Sep 17 00:00:00 2001 From: Christian Rodrigues Date: Wed, 8 Oct 2025 16:43:39 -0700 Subject: [PATCH 1/2] client(feat): add deadline field to ticket management with input and display --- client/components/tickets/ticket-create.tsx | 14 +++++- client/components/tickets/ticket-detail.tsx | 50 +++++++++++++++++++++ client/components/tickets/ticket-edit.tsx | 27 ++++++++++- client/components/tickets/tickets-table.tsx | 44 +++++++++++++++++- server/internal/models/ticket.go | 3 ++ server/internal/storage/storage.go | 9 ++++ 6 files changed, 142 insertions(+), 5 deletions(-) diff --git a/client/components/tickets/ticket-create.tsx b/client/components/tickets/ticket-create.tsx index 340914d..9b12e12 100644 --- a/client/components/tickets/ticket-create.tsx +++ b/client/components/tickets/ticket-create.tsx @@ -17,7 +17,7 @@ export interface TicketCreateProps { export default function TicketCreate({ onCancel, onCreate }: TicketCreateProps) { const { addToast } = useToast(); const { mutate: createTicket } = useCreateTicket(); - const [formData, setFormData] = useState>({ + const [formData, setFormData] = useState & { deadline?: string }>({ status: "Open", title: "", description: "", @@ -26,6 +26,7 @@ export default function TicketCreate({ onCancel, onCreate }: TicketCreateProps) createdBy: "techsquad@digitalnest.org", site: "Watsonville", category: "Hardware", + deadline: "", }); const [isSaving, setIsSaving] = useState(false); @@ -168,6 +169,17 @@ export default function TicketCreate({ onCancel, onCreate }: TicketCreateProps) + {/* Deadline input */} +
+ + setFormData((prev) => ({ ...prev, deadline: e.target.value }))} + /> +
+ {/* Deadline input in the edit form */} +
+ + setFormData((prev) => ({ ...(prev as any), deadline: e.target.value }))} + className="w-full p-2 border rounded" + /> +
diff --git a/server/internal/models/ticket.go b/server/internal/models/ticket.go index 0b5a018..1159052 100644 --- a/server/internal/models/ticket.go +++ b/server/internal/models/ticket.go @@ -20,6 +20,7 @@ type Ticket struct { Status string `json:"status"` CreatedOn time.Time `json:"createdOn"` UpdatedAt time.Time `json:"updatedAt"` + Deadline time.Time `json:"deadline"` } // UnmarshalBSON provides a custom unmarshal implementation for Ticket, enabling @@ -39,6 +40,7 @@ func (t *Ticket) UnmarshalBSON(data []byte) error { Status string `json:"status"` CreatedOn time.Time `json:"createdOn"` UpdatedAt time.Time `json:"updatedAt"` + Deadline time.Time `json:"deadline"` } ) _, _ = buffer.Write(data) @@ -62,6 +64,7 @@ func (t *Ticket) UnmarshalBSON(data []byte) error { Status: result.Status, CreatedOn: result.CreatedOn, UpdatedAt: result.UpdatedAt, + Deadline: result.Deadline, } return nil diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go index 42bd96e..b03f383 100644 --- a/server/internal/storage/storage.go +++ b/server/internal/storage/storage.go @@ -168,6 +168,15 @@ func (s *TicketStore) UpdateTicket(ctx context.Context, id string, updates map[s updatesDoc = append(updatesDoc, bson.E{Key: "status", Value: status}) } + if deadline, ok := updates["deadline"].(string); ok { + parsedDeadline, err := time.Parse(time.RFC3339, deadline) + if err != nil { + sugar.Debugw("failed to parse deadline", "error", err, "value", deadline) + return nil, err + } + updatesDoc = append(updatesDoc, bson.E{Key: "deadline", Value: parsedDeadline}) + } + if len(updates) > 0 { updatesDoc = append(updatesDoc, bson.E{Key: "updatedAt", Value: time.Now()}) } From 6007c92d1c750a5dbd13b78c8925516e7c15474b Mon Sep 17 00:00:00 2001 From: Christian Rodrigues Date: Thu, 9 Oct 2025 16:47:46 -0700 Subject: [PATCH 2/2] client(feat): implement deadline handling for ticket creation, editing, and display --- client/components/tickets/ticket-create.tsx | 40 ++++++---- client/components/tickets/ticket-detail.tsx | 73 ++++++++++++------- client/components/tickets/ticket-edit.tsx | 56 ++++++++++---- .../components/tickets/tickets-table-row.tsx | 9 ++- client/components/tickets/tickets-table.tsx | 60 ++++++++++----- client/lib/types/ticket.ts | 1 + server/internal/models/ticket.go | 48 ++++++------ server/internal/storage/storage.go | 23 +++++- 8 files changed, 208 insertions(+), 102 deletions(-) diff --git a/client/components/tickets/ticket-create.tsx b/client/components/tickets/ticket-create.tsx index 9b12e12..1fe4890 100644 --- a/client/components/tickets/ticket-create.tsx +++ b/client/components/tickets/ticket-create.tsx @@ -17,7 +17,7 @@ export interface TicketCreateProps { export default function TicketCreate({ onCancel, onCreate }: TicketCreateProps) { const { addToast } = useToast(); const { mutate: createTicket } = useCreateTicket(); - const [formData, setFormData] = useState & { deadline?: string }>({ + const [formData, setFormData] = useState, "deadline"> & { deadline?: string }>({ status: "Open", title: "", description: "", @@ -55,17 +55,25 @@ export default function TicketCreate({ onCancel, onCreate }: TicketCreateProps) setIsSaving(true); try { - createTicket(formData, { - onSuccess: (ticket) => { - onCreate(ticket); - addToast("New ticket created successfully!", "Success", 3500); - }, - onError: (err) => { - console.error("Error creating ticket:", err); - addToast("An unexpected error occurred. Please try again.", "Error", 5000); - }, - onSettled: () => setIsSaving(false), - }); + // Prepare the ticket data, converting deadline string to Date and excluding empty deadline + const { deadline, ...rest } = formData; + const ticketData: Partial = { ...rest } as Partial; + + if (deadline && deadline !== "") { + ticketData.deadline = new Date(deadline); + } + + createTicket(ticketData, { + onSuccess: (ticket) => { + onCreate(ticket); + addToast("New ticket created successfully!", "Success", 3500); + }, + onError: (err) => { + console.error("Error creating ticket:", err); + addToast("An unexpected error occurred. Please try again.", "Error", 5000); + }, + onSettled: () => setIsSaving(false), + }); } catch (error) { console.error("Unexpected error:", error); setIsSaving(false); @@ -169,12 +177,12 @@ export default function TicketCreate({ onCancel, onCreate }: TicketCreateProps) - {/* Deadline input */} + {/* Deadline input with date and time */}
- + setFormData((prev) => ({ ...prev, deadline: e.target.value }))} diff --git a/client/components/tickets/ticket-detail.tsx b/client/components/tickets/ticket-detail.tsx index 5b6eb1d..5d64ca7 100644 --- a/client/components/tickets/ticket-detail.tsx +++ b/client/components/tickets/ticket-detail.tsx @@ -64,18 +64,40 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe ); } - const ticketCreatedAt = new Date(ticket.createdOn).toDateString(); - const ticketUpdatedAt = new Date(ticket.updatedAt).toDateString(); - - // helper: format date-only strings (YYYY-MM-DD) into local date without TZ shift - const formatDateFromDateOnly = (raw?: string | null) => { + const ticketCreatedAt = new Date(ticket.createdOn).toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + const ticketUpdatedAt = new Date(ticket.updatedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + // helper: format datetime strings with both date and time (no seconds) + const formatDateTime = (raw?: string | Date | null) => { if (!raw) return null; - const datePart = String(raw).includes("T") ? String(raw).split("T")[0] : String(raw); - const parts = datePart.split("-"); - if (parts.length !== 3) return null; - const [y, m, d] = parts.map((p) => Number(p)); - const dt = new Date(y, m - 1, d); - return dt.toLocaleDateString(); + try { + const date = new Date(raw); + if (isNaN(date.getTime())) return null; + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } catch { + return null; + } }; // screenshot: ensure absolute URL when server returns relative path @@ -115,21 +137,20 @@ export default function TicketDetail({ ticketId, onDismiss, onUpdate }: TicketDe }; // compute deadline display and overdue - // ticket may not have the `deadline` property on the inferred Ticket type, - // so read it into a local variable with a narrow/known type to avoid TS errors. - const deadline = (ticket as any).deadline as string | null | undefined; - const deadlineDisplay = formatDateFromDateOnly(deadline); - const isOverdue = (() => { - if (!deadline) return false; - const datePart = String(deadline).includes("T") ? String(deadline).split("T")[0] : String(deadline); - const parts = datePart.split("-"); - if (parts.length !== 3) return false; - const [y, m, d] = parts.map(Number); - const dt = new Date(y, m - 1, d); - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); - return dt.getTime() < todayStart.getTime(); - })(); + // ticket may not have the `deadline` property on the inferred Ticket type, + // so read it into a local variable with a narrow/known type to avoid TS errors. + const deadline = (ticket as any).deadline as string | Date | null | undefined; + const deadlineDisplay = formatDateTime(deadline); + const isOverdue = (() => { + if (!deadline) return false; + try { + const deadlineDate = new Date(deadline); + const now = new Date(); + return deadlineDate.getTime() < now.getTime(); + } catch { + return false; + } + })(); return (
diff --git a/client/components/tickets/ticket-edit.tsx b/client/components/tickets/ticket-edit.tsx index b3f2ce8..97e3387 100644 --- a/client/components/tickets/ticket-edit.tsx +++ b/client/components/tickets/ticket-edit.tsx @@ -20,18 +20,33 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro const { data: ticket } = useTicket(ticketId); const { mutate: updateTicket } = useUpdateTicket(); - const [formData, setFormData] = useState & { deadline?: string }>(ticket || {}); + const [formData, setFormData] = useState & { deadline?: string | Date }>(ticket || {}); const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (ticket) { - setFormData((prev) => ({ - ...prev, - // normalize deadline to YYYY-MM-DD for the date input - constDeadline: undefined, - deadline: (ticket as any).deadline ? (String((ticket as any).deadline).includes("T") ? String((ticket as any).deadline).split("T")[0] : String((ticket as any).deadline)) : "", - // ...existing fields... - })); + setFormData((prev) => { + // Format deadline for datetime-local input (YYYY-MM-DDTHH:MM) + let deadlineString = ""; + if (ticket.deadline) { + const deadlineDate = new Date(ticket.deadline); + if (!isNaN(deadlineDate.getTime())) { + // Convert to local datetime string format for datetime-local input + const year = deadlineDate.getFullYear(); + const month = String(deadlineDate.getMonth() + 1).padStart(2, '0'); + const day = String(deadlineDate.getDate()).padStart(2, '0'); + const hours = String(deadlineDate.getHours()).padStart(2, '0'); + const minutes = String(deadlineDate.getMinutes()).padStart(2, '0'); + deadlineString = `${year}-${month}-${day}T${hours}:${minutes}`; + } + } + + return { + ...prev, + ...ticket, + deadline: deadlineString, + } as Partial & { deadline?: string }; + }); } }, [ticket]); @@ -78,8 +93,21 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro setIsSaving(true); try { - updateTicket({ id: ticketId, updates: formData }); - onSave(formData); + // Prepare the updates, converting deadline string to Date if needed + const { deadline, ...rest } = formData; + const updates: Partial = { ...rest } as Partial; + + // If deadline is provided as a string, convert it to Date + if (deadline && deadline !== "") { + if (typeof deadline === "string") { + updates.deadline = new Date(deadline); + } else { + updates.deadline = deadline; + } + } + + updateTicket({ id: ticketId, updates }); + onSave(updates); addToast("Ticket updated successfully.", "Info", 2500); } catch (error) { console.error("Error updating ticket:", error); @@ -196,15 +224,15 @@ export default function TicketEdit({ ticketId, onCancel, onSave }: TicketEditPro
- {/* Deadline input in the edit form */} + {/* Deadline input with date and time */}
- + setFormData((prev) => ({ ...(prev as any), deadline: e.target.value }))} - className="w-full p-2 border rounded" + className="w-full p-2 border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-300 rounded-md" />
diff --git a/client/components/tickets/tickets-table-row.tsx b/client/components/tickets/tickets-table-row.tsx index 7665900..bdde0d0 100644 --- a/client/components/tickets/tickets-table-row.tsx +++ b/client/components/tickets/tickets-table-row.tsx @@ -9,7 +9,14 @@ export interface TicketsTableRowProps { } export default function TicketTableRow({ ticket, onClick }: TicketsTableRowProps) { - const ticketUpdatedAt = new Date(ticket.updatedAt).toDateString(); + const ticketUpdatedAt = new Date(ticket.updatedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); const columnStyles = "px-4 py-4 text-left text-gray-700 dark:text-gray-300"; let statusStyle: string; diff --git a/client/components/tickets/tickets-table.tsx b/client/components/tickets/tickets-table.tsx index d7c2b1c..edcda1a 100644 --- a/client/components/tickets/tickets-table.tsx +++ b/client/components/tickets/tickets-table.tsx @@ -36,12 +36,35 @@ export default function TicketsTable({ tickets, onClick }: TicketsTableProps) { {tickets.map((ticket) => { - const dateRaw = (ticket as any).deadline; - const datePart = dateRaw ? (String(dateRaw).includes("T") ? String(dateRaw).split("T")[0] : String(dateRaw)) : ""; - const deadlineText = datePart ? (() => { - const [y, m, d] = datePart.split("-").map(Number); - return new Date(y, m - 1, d).toLocaleDateString(); - })() : ""; + // Format deadline with both date and time (no seconds) + const deadline = (ticket as any).deadline; + const deadlineDisplay = deadline ? (() => { + try { + const date = new Date(deadline); + return isNaN(date.getTime()) ? null : date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } catch { + return null; + } + })() : null; + + // Check if deadline is overdue + const isOverdue = deadline ? (() => { + try { + const deadlineDate = new Date(deadline); + const now = new Date(); + return deadlineDate.getTime() < now.getTime(); + } catch { + return false; + } + })() : false; + return ( onClick(ticket)}> P{ticket.priority ?? "—"} @@ -50,23 +73,26 @@ export default function TicketsTable({ tickets, onClick }: TicketsTableProps) { {ticket.assignedTo ?? "—"} {ticket.status ?? "—"} - {/* Deadline cell */} + {/* Deadline cell with date and time */} - {deadlineText ? ( + {deadlineDisplay ? (
- {deadlineText} - {(() => { - if (!datePart) return null; - const [y, m, d] = datePart.split("-").map(Number); - const dt = new Date(y, m - 1, d); - const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); - return dt.getTime() < todayStart.getTime() ? Overdue : null; - })()} + {deadlineDisplay} + {isOverdue && Overdue}
) : } - {ticket.updatedAt ? new Date(ticket.updatedAt).toLocaleString() : "—"} + + {ticket.updatedAt ? new Date(ticket.updatedAt).toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }) : "—"} + ); })} diff --git a/client/lib/types/ticket.ts b/client/lib/types/ticket.ts index af6456e..8d1c7ee 100644 --- a/client/lib/types/ticket.ts +++ b/client/lib/types/ticket.ts @@ -10,6 +10,7 @@ export default interface Ticket { status: Status; createdOn: Date; updatedAt: Date; + deadline?: Date; } export const Sites = ["Salinas", "Watsonville", "HQ", "Gilroy", "Modesto", "Stockton"] as const; diff --git a/server/internal/models/ticket.go b/server/internal/models/ticket.go index 1159052..f42a8c4 100644 --- a/server/internal/models/ticket.go +++ b/server/internal/models/ticket.go @@ -9,18 +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"` - Deadline time.Time `json:"deadline"` + 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"` + Deadline *time.Time `json:"deadline,omitempty"` } // UnmarshalBSON provides a custom unmarshal implementation for Ticket, enabling @@ -29,18 +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"` - Deadline time.Time `json:"deadline"` + 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"` + Deadline *time.Time `json:"deadline,omitempty"` } ) _, _ = buffer.Write(data) diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go index b03f383..2213694 100644 --- a/server/internal/storage/storage.go +++ b/server/internal/storage/storage.go @@ -65,6 +65,11 @@ func (s *TicketStore) CreateTicket(ctx context.Context, ticket models.Ticket) (i } ) + // Add deadline if it's provided + if ticket.Deadline != nil { + doc = append(doc, bson.E{Key: "deadline", Value: *ticket.Deadline}) + } + res, err := s.collection.InsertOne(ctx, doc) if err != nil { return "", err @@ -169,11 +174,21 @@ func (s *TicketStore) UpdateTicket(ctx context.Context, id string, updates map[s } if deadline, ok := updates["deadline"].(string); ok { - parsedDeadline, err := time.Parse(time.RFC3339, deadline) - if err != nil { - sugar.Debugw("failed to parse deadline", "error", err, "value", deadline) - return nil, err + var parsedDeadline time.Time + var err error + + // Try datetime-local format first (YYYY-MM-DDTHH:MM) + if parsedDeadline, err = time.Parse("2006-01-02T15:04", deadline); err != nil { + // Try date-only format (YYYY-MM-DD) + if parsedDeadline, err = time.Parse("2006-01-02", deadline); err != nil { + // Try RFC3339 format as fallback + if parsedDeadline, err = time.Parse(time.RFC3339, deadline); err != nil { + sugar.Debugw("failed to parse deadline", "error", err, "value", deadline) + return nil, err + } + } } + updatesDoc = append(updatesDoc, bson.E{Key: "deadline", Value: parsedDeadline}) }