diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx index cfdcf7d7..ea7ebf00 100644 --- a/src/app/(protected)/workorders/[id].tsx +++ b/src/app/(protected)/workorders/[id].tsx @@ -1,39 +1,21 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { Alert, Box, CircularProgress } from "@mui/material"; import { DispatchCreateModal } from "./_components/dispatch-create-modal"; import { DispatchDetailModal } from "./_components/dispatch-detail-modal"; -import { VendorEditorDialog } from "./_components/vendor-editor-dialog"; -import { useVendorEditor } from "./_components/use-vendor-editor"; -import { WorkOrderActivity } from "./_components/work-order-activity"; -import { - DispatchAccordion, - WorkOrderDetailsSection, - WorkOrderHeader, - WorkOrderSummaryAccordions, -} from "./_components/work-order-view-sections"; -import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; -import { - useChangeWorkOrderAssignment, - useChangeWorkOrderStatus, -} from "@/domain/work-orders/use-cases/use-work-order-mutations"; -import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { WorkOrderActivityPanel } from "./_components/view/work-order-activity-panel"; +import { WorkOrderDispatchesAccordion } from "./_components/view/work-order-dispatches-accordion"; +import { WorkOrderVendorEditorDialog } from "./_components/view/work-order-vendor-editor-dialog"; +import { WorkOrderViewAccordions } from "./_components/view/work-order-view-accordions"; +import { WorkOrderViewDetails } from "./_components/view/work-order-view-details"; +import { WorkOrderViewHeader } from "./_components/view/work-order-view-header"; +import { useWorkOrderViewPage } from "./_hooks/use-work-order-view-page"; export default function WorkOrderViewPage() { const { id } = useParams(); - const navigate = useNavigate(); const workOrderId = id ?? ""; + const view = useWorkOrderViewPage(workOrderId); - const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); - const { data: users = [] } = useWorkOrderUsers(); - const statusMutation = useChangeWorkOrderStatus(workOrderId); - const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); - const vendorEditor = useVendorEditor({ workOrderId, onSaved: () => void refetch() }); - - const [dispatchOpen, setDispatchOpen] = useState(false); - const [dispatchDetailId, setDispatchDetailId] = useState(null); - - if (isLoading) { + if (view.isLoading) { return ( @@ -41,11 +23,11 @@ export default function WorkOrderViewPage() { ); } - if (error || !wo) { + if (view.error || !view.wo) { return ( - {error instanceof Error ? error.message : "Work order not found."} + {view.error instanceof Error ? view.error.message : "Work order not found."} ); @@ -53,56 +35,67 @@ export default function WorkOrderViewPage() { return ( - statusMutation.mutate(status)} - onDispatch={() => setDispatchOpen(true)} - onEdit={() => navigate(`/workorders/edit/${id}`)} - onBack={() => navigate("/workorders")} - /> - assignmentMutation.mutate(payload)} - /> - - - void vendorEditor.open(event, vendorId, vendorName) - } - onViewDispatch={setDispatchDetailId} - /> - view.statusMutation.mutate(status)} + onOpenDispatch={() => view.setDispatchOpen(true)} /> - {dispatchOpen && ( + + view.assignmentMutation.mutate({ userId, userName })} + onUnassign={() => view.assignmentMutation.mutate({ userId: "", userName: "" })} + /> + + + + + + + + {view.dispatchOpen && ( setDispatchOpen(false)} - onDispatched={() => void refetch()} + workOrder={view.wo} + onClose={() => view.setDispatchOpen(false)} + onDispatched={() => void view.refetch()} /> )} - {dispatchDetailId != null && ( + {view.dispatchDetailId != null && ( setDispatchDetailId(null)} - onUpdated={() => void refetch()} + onClose={() => view.setDispatchDetailId(null)} + onUpdated={() => void view.refetch()} /> )} - { + if (!view.vendorMutation.isPending) view.setVendorEditor(null); + }} + onSave={view.saveVendorEditor} + onEditorChange={view.setVendorEditor} /> ); diff --git a/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx b/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx new file mode 100644 index 00000000..ca276156 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-activity-panel.tsx @@ -0,0 +1,107 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Paper, Tab, Tabs } from "@mui/material"; +import { CommentMentionComposer } from "@/components/common/comment-mention-composer"; +import { CommentTimeline } from "@/components/common/comment-timeline"; +import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers"; +import type { + WorkOrderAuditEntry, + WorkOrderComment, +} from "@/domain/work-orders/types/work-order-detail"; +import { WorkOrderAuditTimeline } from "@/app/(protected)/workorders/_components/view/work-order-audit-timeline"; +import { Text } from "@/components/ui/text"; + +export type WorkOrderActivityTab = "customer" | "internal" | "audit"; + +type MentionUser = { id: string | number; name: string }; + +type WorkOrderActivityPanelProps = { + customerComments: WorkOrderComment[]; + internalComments: WorkOrderComment[]; + auditLog: WorkOrderAuditEntry[]; + mentionUsers: MentionUser[]; + isSubmitting: boolean; + onSubmitComment: (text: string) => void | Promise; +}; + +export function WorkOrderActivityPanel({ + customerComments, + internalComments, + auditLog, + mentionUsers, + isSubmitting, + onSubmitComment, +}: WorkOrderActivityPanelProps) { + const [activeTab, setActiveTab] = useState("customer"); + const [commentText, setCommentText] = useState(""); + + const customerTimelineItems = useMemo( + () => mapWorkOrderCommentsToTimeline(customerComments), + [customerComments], + ); + const internalTimelineItems = useMemo( + () => mapWorkOrderCommentsToTimeline(internalComments), + [internalComments], + ); + + return ( + + + Activity + + setActiveTab(value)} + className="mb-3" + > + + + + + + {activeTab === "customer" && ( + + )} + {activeTab === "internal" && ( + + )} + {activeTab === "audit" && } + + {activeTab === "internal" && ( + { + event.preventDefault(); + const trimmed = commentText.trim(); + if (!trimmed) { + return; + } + void (async () => { + try { + await onSubmitComment(trimmed); + setCommentText(""); + } catch { + // Mutation toast handles failure; keep the draft for retry. + } + })(); + }} + className="mt-4 flex flex-col gap-2" + > + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx b/src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx new file mode 100644 index 00000000..46c7b374 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-audit-timeline.tsx @@ -0,0 +1,38 @@ +import { useMemo } from "react"; +import { Box, Stack, Typography } from "@mui/material"; +import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order"; +import { formatDateTime } from "@/lib/time-utils"; + +export function WorkOrderAuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) { + const sorted = useMemo( + () => + [...entries].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ), + [entries], + ); + + if (!sorted.length) { + return No changes recorded.; + } + + return ( + + {sorted.map((entry) => ( + + + + {entry.userName || "System"} + + + {entry.createdAt ? formatDateTime(entry.createdAt) : ""} + + + + {entry.fieldName} {entry.oldValue} → {entry.newValue} + + + ))} + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-detail-field.tsx b/src/app/(protected)/workorders/_components/view/work-order-detail-field.tsx new file mode 100644 index 00000000..1e9f5184 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-detail-field.tsx @@ -0,0 +1,12 @@ +import { Box, Typography } from "@mui/material"; + +export function WorkOrderDetailField({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + {value} + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx new file mode 100644 index 00000000..0d77c0f0 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-dispatches-accordion.tsx @@ -0,0 +1,113 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils"; + +type WorkOrderDispatchesAccordionProps = { + wo: WorkOrderDetail; + onOpenDispatchDetail: (dispatchId: string | number) => void; + onOpenVendorEditor: ( + event: React.MouseEvent, + vendorId: string | number, + vendorName: string, + ) => void; +}; + +export function WorkOrderDispatchesAccordion({ + wo, + onOpenDispatchDetail, + onOpenVendorEditor, +}: WorkOrderDispatchesAccordionProps) { + return ( + + }> + Dispatches ({wo.dispatches.length}) + + + {wo.dispatches.length ? ( + + + + + Dispatch # + Vendor + NTE + Status + Date + Wait + Actions + + + + {wo.dispatches.map((dispatch) => { + const waitColor = waitTimeColor(dispatch.dispatchedAt); + return ( + onOpenDispatchDetail(dispatch.id)} + > + {dispatch.dispatchNumber || dispatch.poNumber} + {dispatch.vendorName} + + {dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"} + + {dispatch.status} + + {dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"} + + + {dispatch.status === "Sent" ? ( + + {timeSince(dispatch.dispatchedAt)} + + ) : ( + "—" + )} + + + + + + ); + })} + +
+
+ ) : ( + No dispatches yet. + )} +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx new file mode 100644 index 00000000..b89ef2e7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-dialog.tsx @@ -0,0 +1,114 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { VendorEditorState } from "./work-order-vendor-editor-types"; +import { formatPhoneMask, isVendorEditorValid } from "./work-order-vendor-editor-utils"; + +type WorkOrderVendorEditorDialogProps = { + editor: VendorEditorState | null; + loading: boolean; + error: string; + isSaving: boolean; + onClose: () => void; + onSave: () => void; + onEditorChange: ( + updater: (current: VendorEditorState | null) => VendorEditorState | null, + ) => void; +}; + +export function WorkOrderVendorEditorDialog({ + editor, + loading, + error, + isSaving, + onClose, + onSave, + onEditorChange, +}: WorkOrderVendorEditorDialogProps) { + return ( + + Edit assigned vendor + + + + {editor?.vendorName} + + {Boolean(error) && {error}} + {loading ? ( + + + + ) : ( + <> + + onEditorChange((current) => + current ? { ...current, contactName: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 200 } }} + /> + + onEditorChange((current) => + current ? { ...current, phone: formatPhoneMask(event.target.value) } : current, + ) + } + error={Boolean(editor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone))} + helperText="Use (XXX) XXX-XXXX" + /> + + onEditorChange((current) => + current ? { ...current, email: event.target.value } : current, + ) + } + /> + + onEditorChange((current) => + current ? { ...current, notes: event.target.value } : current, + ) + } + slotProps={{ htmlInput: { maxLength: 2000 } }} + /> + + )} + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts new file mode 100644 index 00000000..7504b91b --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-types.ts @@ -0,0 +1,8 @@ +export interface VendorEditorState { + vendorId: string | number; + vendorName: string; + contactName: string; + phone: string; + email: string; + notes: string; +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts new file mode 100644 index 00000000..a73099ab --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils.ts @@ -0,0 +1,16 @@ +import type { VendorEditorState } from "./work-order-vendor-editor-types"; + +export function formatPhoneMask(input: string): string { + const digits = input.replace(/\D/g, "").slice(0, 10); + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + +export function isVendorEditorValid(editor: VendorEditorState): boolean { + const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone); + const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email); + return ( + phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200 + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx new file mode 100644 index 00000000..707d8d61 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-view-accordions.tsx @@ -0,0 +1,66 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field"; +import { formatDateTime } from "@/lib/time-utils"; + +export function WorkOrderViewAccordions({ wo }: { wo: WorkOrderDetail }) { + return ( + <> + + }> + Classification + + + + + + + + + + + + }> + Schedule + + + + + + + + + + + + + }> + Financial + + + + + + + + + + + + }> + Description + + + {wo.description || "No description provided."} + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx new file mode 100644 index 00000000..bf283bfd --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-view-details.tsx @@ -0,0 +1,73 @@ +import { Box, Button, Menu, MenuItem, Paper, Typography } from "@mui/material"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; +import { WorkOrderDetailField } from "@/app/(protected)/workorders/_components/view/work-order-detail-field"; +import { Text } from "@/components/ui/text"; + +type WorkOrderViewDetailsProps = { + wo: WorkOrderDetail; + users: WorkOrderUser[]; + assignAnchor: HTMLElement | null; + onAssignAnchorChange: (anchor: HTMLElement | null) => void; + onAssign: (userId: string | number, userName: string) => void; + onUnassign: () => void; +}; + +export function WorkOrderViewDetails({ + wo, + users, + assignAnchor, + onAssignAnchorChange, + onAssign, + onUnassign, +}: WorkOrderViewDetailsProps) { + return ( + + + Details + + + + + + + + + Assigned To + + + onAssignAnchorChange(null)} + > + { + onAssignAnchorChange(null); + onUnassign(); + }} + > + Unassigned + + {users.map((user) => ( + { + onAssignAnchorChange(null); + onAssign(user.id, user.name); + }} + > + {user.name} + + ))} + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx new file mode 100644 index 00000000..101abf72 --- /dev/null +++ b/src/app/(protected)/workorders/_components/view/work-order-view-header.tsx @@ -0,0 +1,95 @@ +import { Link as RouterLink, useNavigate } from "react-router"; +import { Breadcrumbs, Button, Chip, Link, Menu, MenuItem, Stack, Typography } from "@mui/material"; +import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order-detail"; + +const STATUS_COLORS: Record< + string, + "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" +> = { + Open: "primary", + "In Progress": "info", + "On Hold": "warning", + Done: "success", + Cancelled: "default", +}; + +type WorkOrderViewHeaderProps = { + wo: WorkOrderDetail; + workOrderId: string; + statusAnchor: HTMLElement | null; + onStatusAnchorChange: (anchor: HTMLElement | null) => void; + onStatusChange: (status: string) => void; + onOpenDispatch: () => void; +}; + +export function WorkOrderViewHeader({ + wo, + workOrderId, + statusAnchor, + onStatusAnchorChange, + onStatusChange, + onOpenDispatch, +}: WorkOrderViewHeaderProps) { + const navigate = useNavigate(); + + return ( + <> + + + Work Orders + + {wo.internalWONumber} + + + + + {wo.title} + + onStatusAnchorChange(event.currentTarget)} + className="w-fit cursor-pointer" + /> + onStatusAnchorChange(null)} + > + {WORK_ORDER_STATUS_OPTIONS.map((status) => ( + { + onStatusAnchorChange(null); + if (status !== wo.status) { + onStatusChange(status); + } + }} + > + {status} + + ))} + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts new file mode 100644 index 00000000..d2dc9183 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-work-order-view-page.ts @@ -0,0 +1,149 @@ +import { useMemo, useState } from "react"; +import type { VendorEditorState } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-types"; +import { isVendorEditorValid } from "@/app/(protected)/workorders/_components/view/work-order-vendor-editor-utils"; +import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { + useAddWorkOrderComment, + useChangeWorkOrderAssignment, + useChangeWorkOrderStatus, + useUpdateWorkOrderVendor, +} from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; +import { vendorsApi } from "@/domain/vendors/api/vendors-api"; +import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions"; + +export function useWorkOrderViewPage(workOrderId: string) { + const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId); + const { data: users = [] } = useWorkOrderUsers(); + const statusMutation = useChangeWorkOrderStatus(workOrderId); + const assignmentMutation = useChangeWorkOrderAssignment(workOrderId); + const commentMutation = useAddWorkOrderComment(workOrderId); + const vendorMutation = useUpdateWorkOrderVendor(workOrderId); + + const [dispatchOpen, setDispatchOpen] = useState(false); + const [dispatchDetailId, setDispatchDetailId] = useState(null); + const [statusAnchor, setStatusAnchor] = useState(null); + const [assignAnchor, setAssignAnchor] = useState(null); + const [vendorEditor, setVendorEditor] = useState(null); + const [vendorEditorLoading, setVendorEditorLoading] = useState(false); + const [vendorEditorError, setVendorEditorError] = useState(""); + + const customerComments = useMemo( + () => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"), + [wo?.comments], + ); + const internalComments = useMemo( + () => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"), + [wo?.comments], + ); + const auditLog = wo?.auditLog ?? []; + const mentionUsers = useMemo( + () => users.map((user) => ({ id: user.id, name: user.name })), + [users], + ); + + const submitComment = async (text: string) => { + const mentions = extractMentionIds(text); + await commentMutation.mutateAsync({ + text, + mentions: mentions.length > 0 ? mentions : undefined, + commentType: "internal", + }); + }; + + const openVendorEditor = async ( + event: React.MouseEvent, + vendorId: string | number, + vendorName: string, + ) => { + event.stopPropagation(); + setVendorEditorError(""); + setVendorEditorLoading(true); + setVendorEditor({ + vendorId, + vendorName, + contactName: "", + phone: "", + email: "", + notes: "", + }); + + try { + const vendor = await vendorsApi.getById(vendorId); + setVendorEditor({ + vendorId, + vendorName: vendor.companyName || vendorName, + contactName: vendor.contactName, + phone: vendor.phone, + email: vendor.email, + notes: vendor.notes, + }); + } catch (vendorError) { + setVendorEditorError( + vendorError instanceof Error ? vendorError.message : "Unable to load vendor details", + ); + } finally { + setVendorEditorLoading(false); + } + }; + + const saveVendorEditor = () => { + if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return; + const numericWorkOrderId = Number(workOrderId); + if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) { + setVendorEditorError("A valid work order is required"); + return; + } + + vendorMutation.mutate( + { + vendorId: vendorEditor.vendorId, + payload: { + workOrderId: numericWorkOrderId, + contactName: vendorEditor.contactName.trim(), + phone: vendorEditor.phone, + email: vendorEditor.email.trim(), + notes: vendorEditor.notes.trim(), + }, + }, + { + onSuccess: () => { + setVendorEditor(null); + void refetch(); + }, + onError: (vendorError) => setVendorEditorError(vendorError.message), + }, + ); + }; + + return { + wo, + isLoading, + error, + refetch, + users, + customerComments, + internalComments, + auditLog, + mentionUsers, + submitComment, + dispatchOpen, + setDispatchOpen, + dispatchDetailId, + setDispatchDetailId, + statusAnchor, + setStatusAnchor, + assignAnchor, + setAssignAnchor, + vendorEditor, + setVendorEditor, + vendorEditorLoading, + vendorEditorError, + openVendorEditor, + saveVendorEditor, + statusMutation, + assignmentMutation, + commentMutation, + vendorMutation, + }; +} diff --git a/src/components/common/comment-body-with-mentions.tsx b/src/components/common/comment-body-with-mentions.tsx new file mode 100644 index 00000000..a7566e70 --- /dev/null +++ b/src/components/common/comment-body-with-mentions.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { Box } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import { + buildCommentBodySegments, + type MentionLookup, +} from "@/domain/work-orders/utils/comment-mentions"; + +type CommentBodyWithMentionsProps = { + text: string; + mentions?: string[]; + users?: MentionLookup[]; +}; + +export function CommentBodyWithMentions({ text, mentions, users }: CommentBodyWithMentionsProps) { + const segments = useMemo( + () => buildCommentBodySegments(text, mentions, users), + [text, mentions, users], + ); + + if (segments.length === 0) { + return null; + } + + return ( + + {segments.map((segment, index) => { + if (segment.type === "text") { + return {segment.value}; + } + + return ( + + @{segment.label} + + ); + })} + + ); +} diff --git a/src/components/common/comment-mention-composer.tsx b/src/components/common/comment-mention-composer.tsx new file mode 100644 index 00000000..140753b0 --- /dev/null +++ b/src/components/common/comment-mention-composer.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + ClickAwayListener, + List, + ListItemButton, + Paper, + Popper, + TextField, +} from "@mui/material"; +import { + filterMentionCandidates, + findActiveMentionQuery, + insertMentionToken, + type MentionLookup, +} from "@/domain/work-orders/utils/comment-mentions"; + +type CommentMentionComposerProps = { + value: string; + onChange: (value: string) => void; + candidates: MentionLookup[]; + placeholder?: string; + disabled?: boolean; + rows?: number; + onSubmit?: () => void; +}; + +type MentionCandidateMenuProps = { + open: boolean; + anchorEl: HTMLElement | null; + candidates: MentionLookup[]; + highlightIndex: number; + onSelect: (user: MentionLookup) => void; +}; + +function MentionCandidateMenu({ + open, + anchorEl, + candidates, + highlightIndex, + onSelect, +}: MentionCandidateMenuProps) { + return ( + + + + {candidates.map((candidate, index) => ( + event.preventDefault()} + onClick={() => onSelect(candidate)} + > + @{candidate.name} + + ))} + + + + ); +} + +export function CommentMentionComposer({ + value, + onChange, + candidates, + placeholder = "Type a comment...", + disabled = false, + rows = 2, + onSubmit, +}: CommentMentionComposerProps) { + const inputRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const [cursor, setCursor] = useState(0); + const [menuOpen, setMenuOpen] = useState(false); + const [highlightIndex, setHighlightIndex] = useState(0); + + const activeQuery = useMemo(() => findActiveMentionQuery(value, cursor), [value, cursor]); + + const filteredCandidates = useMemo( + () => filterMentionCandidates(candidates, activeQuery?.query ?? ""), + [activeQuery?.query, candidates], + ); + + const showMenu = Boolean(menuOpen && activeQuery && filteredCandidates.length > 0); + + useEffect(() => { + setHighlightIndex(0); + }, [activeQuery?.query, activeQuery?.start]); + + const syncCursor = useCallback((textOverride?: string) => { + const element = inputRef.current; + if (!element) { + return; + } + + const text = textOverride ?? element.value; + const nextCursor = element.selectionStart ?? text.length; + setCursor(nextCursor); + setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor))); + }, []); + + const handleChange = (nextValue: string) => { + onChange(nextValue); + requestAnimationFrame(() => { + syncCursor(nextValue); + }); + }; + + const applyMention = useCallback( + (user: MentionLookup) => { + if (!activeQuery) { + return; + } + + const { nextText, nextCursor } = insertMentionToken(value, activeQuery.start, cursor, user); + onChange(nextText); + setMenuOpen(false); + setHighlightIndex(0); + + requestAnimationFrame(() => { + const element = inputRef.current; + if (!element) { + return; + } + element.focus(); + element.setSelectionRange(nextCursor, nextCursor); + setCursor(nextCursor); + }); + }, + [activeQuery, cursor, onChange, value], + ); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (showMenu) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlightIndex((current) => (current + 1) % filteredCandidates.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlightIndex( + (current) => (current - 1 + filteredCandidates.length) % filteredCandidates.length, + ); + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + const selected = filteredCandidates[highlightIndex]; + if (selected) { + applyMention(selected); + } + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setMenuOpen(false); + return; + } + } + + if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && onSubmit) { + event.preventDefault(); + onSubmit(); + } + }; + + return ( + setMenuOpen(false)}> + { + setAnchorEl(node); + }} + > + handleChange(event.target.value)} + onClick={() => syncCursor()} + onKeyUp={() => syncCursor()} + onSelect={() => syncCursor()} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + /> + + + + + ); +} diff --git a/src/components/common/comment-timeline-mappers.ts b/src/components/common/comment-timeline-mappers.ts index aad27c4b..324e1230 100644 --- a/src/components/common/comment-timeline-mappers.ts +++ b/src/components/common/comment-timeline-mappers.ts @@ -6,6 +6,7 @@ type CommentSource = { commenter?: string | null; createdDate?: string; commenttext: string; + mentions?: string[]; }; export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): CommentTimelineItem[] { @@ -14,6 +15,7 @@ export function mapWorkOrderCommentsToTimeline(comments: CommentSource[]): Comme author: comment.userName || comment.commenter || "System", createdAt: comment.createdDate, body: comment.commenttext, + mentions: comment.mentions, })); } diff --git a/src/components/common/comment-timeline.tsx b/src/components/common/comment-timeline.tsx index d0404ba7..4f9c9dea 100644 --- a/src/components/common/comment-timeline.tsx +++ b/src/components/common/comment-timeline.tsx @@ -1,5 +1,8 @@ import { useMemo } from "react"; -import { Box, Stack, Typography } from "@mui/material"; +import { Box, Stack } from "@mui/material"; +import { CommentBodyWithMentions } from "@/components/common/comment-body-with-mentions"; +import { Text } from "@/components/ui/text"; +import type { MentionLookup } from "@/domain/work-orders/utils/comment-mentions"; import { formatDateTime } from "@/lib/time-utils"; export type CommentTimelineItem = { @@ -7,18 +10,20 @@ export type CommentTimelineItem = { author: string; createdAt?: string; body: string; + mentions?: string[]; }; type CommentTimelineProps = { items: CommentTimelineItem[]; emptyMessage?: string; maxHeightClass?: string; + mentionUsers?: MentionLookup[]; }; - export function CommentTimeline({ items, emptyMessage = "No comments yet.", maxHeightClass = "max-h-72", + mentionUsers, }: CommentTimelineProps) { const sorted = useMemo( () => @@ -31,7 +36,7 @@ export function CommentTimeline({ ); if (!sorted.length) { - return {emptyMessage}; + return {emptyMessage}; } return ( @@ -39,14 +44,14 @@ export function CommentTimeline({ {sorted.map((item) => ( - + {item.author} - - + + {item.createdAt ? formatDateTime(item.createdAt) : ""} - + - {item.body} + ))} diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index 5532459f..0560785e 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -20,6 +20,11 @@ import type { } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderComment } from "@/domain/work-orders/types/work-order"; +export interface CreateWorkOrderCommentPayload { + text: string; + mentions?: string[]; +} + async function readResponseJson(response: Response): Promise { try { return await response.json(); @@ -57,8 +62,17 @@ export const workOrderBoardDocumentsApi = { return Array.isArray(items) ? items.map(mapBoardDetailComment) : []; }, - createComment: async (workOrderId: string | number, text: string): Promise => { - const data = await apiPost(API_PATHS.workOrder.comments(workOrderId), { text }); + createComment: async ( + workOrderId: string | number, + text: string, + mentions?: string[], + ): Promise => { + const payload: CreateWorkOrderCommentPayload = { text }; + if (mentions && mentions.length > 0) { + payload.mentions = mentions.map(String); + } + + const data = await apiPost(API_PATHS.workOrder.comments(workOrderId), payload); return mapBoardDetailComment(handleApiResponse(data)); }, diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index fffd515b..3b513279 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -61,6 +61,33 @@ function readBoolean(record: Record, ...keys: string[]): boolea return false; } +function readStringArray(record: Record, ...keys: string[]): string[] { + for (const key of keys) { + const value = record[key]; + if (!Array.isArray(value)) { + continue; + } + + const mapped = value + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + if (typeof entry === "number" || typeof entry === "boolean") { + return String(entry); + } + return ""; + }) + .filter(Boolean); + + if (mapped.length > 0) { + return mapped; + } + } + + return []; +} + function mapDocStatus(raw: unknown): CompletionDocStatus { const numeric = typeof raw === "number" ? raw : Number(raw); if (numeric === DOC_STATUS_API.Yes) return "Yes"; @@ -93,6 +120,7 @@ export function mapBoardDetailComment(raw: unknown): WorkOrderComment { authorId, commenter: readString(item, "commenter", "Commenter") || null, recordType: readString(item, "recordType", "RecordType") || null, + mentions: readStringArray(item, "mentions", "Mentions"), }; } diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts index 2b61364c..b2bb5de2 100644 --- a/src/domain/work-orders/mappers/work-order-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-mapper.ts @@ -215,6 +215,12 @@ export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table- export function mapWorkOrderComment(raw: unknown): WorkOrderComment { const item = asRecord(raw); + const mentionsRaw = item.mentions ?? item.Mentions; + const mentions = Array.isArray(mentionsRaw) + ? mentionsRaw + .map((entry) => (typeof entry === "string" ? entry : String(entry))) + .filter(Boolean) + : undefined; return { id: readId(item), @@ -224,6 +230,7 @@ export function mapWorkOrderComment(raw: unknown): WorkOrderComment { userName: readString(item, "userName", "UserName"), commenter: readString(item, "commenter", "Commenter") || null, recordType: readString(item, "recordType", "RecordType") || null, + mentions, }; } diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts index 590aee84..ab3737b2 100644 --- a/src/domain/work-orders/types/work-order-board-detail.ts +++ b/src/domain/work-orders/types/work-order-board-detail.ts @@ -94,6 +94,7 @@ export interface WorkOrderBoardDetailCommentApi { text?: string | null; time?: string | null; documents?: unknown; + mentions?: string[] | null; } export interface WorkOrderBoardDetailAuditApi { diff --git a/src/domain/work-orders/types/work-order-detail.ts b/src/domain/work-orders/types/work-order-detail.ts index 8c492a99..d3d2fa87 100644 --- a/src/domain/work-orders/types/work-order-detail.ts +++ b/src/domain/work-orders/types/work-order-detail.ts @@ -11,6 +11,8 @@ export interface WorkOrderComment { authorId?: string | null; commenter: string | null; recordType: string | null; + /** Mentioned user ids when returned by board comments API. */ + mentions?: string[]; } export interface WorkOrderAuditEntry { diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index 82667f19..e795c290 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -38,8 +38,9 @@ function invalidateBoardDetail( void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) }); } -interface AddWorkOrderCommentInput { +export interface AddWorkOrderCommentInput { text: string; + mentions?: string[]; commentType?: "customer" | "internal"; } @@ -78,12 +79,12 @@ export interface AddDispatchSignoffInput { signatureMethod: string; } -interface ChangeWorkOrderAssignmentInput { +export interface ChangeWorkOrderAssignmentInput { userId: string | number; userName: string; } -interface UpdateWorkOrderVendorInput { +export interface UpdateWorkOrderVendorInput { vendorId: string | number; payload: WorkOrderVendorUpdatePayload; } @@ -157,8 +158,8 @@ export function useAddWorkOrderComment( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ text }: AddWorkOrderCommentInput) => - workOrdersApi.createComment(workOrderId, text), + mutationFn: ({ text, mentions }: AddWorkOrderCommentInput) => + workOrdersApi.createComment(workOrderId, text, mentions), onSuccess: () => { invalidateBoardDetail(queryClient, workOrderId); }, diff --git a/src/domain/work-orders/utils/comment-mentions.ts b/src/domain/work-orders/utils/comment-mentions.ts new file mode 100644 index 00000000..b3542bcd --- /dev/null +++ b/src/domain/work-orders/utils/comment-mentions.ts @@ -0,0 +1,188 @@ +/** Encoded mention token stored in comment text: `@[userId:Display Name]`. */ +export const MENTION_TOKEN_PATTERN = /@\[([^:\]]+):([^\]]+)\]/g; + +export type CommentBodySegment = + { type: "text"; value: string } | { type: "mention"; userId: string; label: string }; + +export type MentionLookup = { + id: string | number; + name: string; +}; + +export function encodeMentionToken(userId: string | number, userName: string): string { + const trimmedName = userName.trim(); + return `@[${String(userId)}:${trimmedName}]`; +} + +export function extractMentionIds(text: string): string[] { + const ids: string[] = []; + const pattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g"); + + for (const match of text.matchAll(pattern)) { + const userId = match[1]; + if (userId && !ids.includes(userId)) { + ids.push(userId); + } + } + + return ids; +} + +export function toDisplayText(text: string): string { + return text.replace(MENTION_TOKEN_PATTERN, (_full, _userId: string, userName: string) => { + return `@${userName}`; + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function pushTextSegment(segments: CommentBodySegment[], value: string): void { + if (!value) { + return; + } + + const last = segments.length > 0 ? segments[segments.length - 1] : undefined; + if (last?.type === "text") { + last.value += value; + return; + } + + segments.push({ type: "text", value }); +} + +function resolveMentionNames( + mentionIds: string[] | undefined, + users: MentionLookup[] | undefined, +): Map { + const byId = new Map(); + const usersById = new Map((users ?? []).map((user) => [String(user.id), user.name] as const)); + + // Only ids that were actually mentioned — never seed the full dispatcher roster. + for (const mentionId of mentionIds ?? []) { + const id = String(mentionId); + byId.set(id, usersById.get(id) ?? id); + } + + return byId; +} + +function appendPlainMentionMatches( + segments: CommentBodySegment[], + plainText: string, + mentionNames: string[], +): void { + if (!plainText || mentionNames.length === 0) { + pushTextSegment(segments, plainText); + return; + } + + const sortedNames = [...mentionNames].sort((a, b) => b.length - a.length); + const namePattern = sortedNames.map((name) => escapeRegExp(name)).join("|"); + // Require a non-word boundary after the name so "@Ann" does not match inside "@Annual". + const mentionRegex = new RegExp(`@(${namePattern})(?![\\w])`, "g"); + + let cursor = 0; + for (const match of plainText.matchAll(mentionRegex)) { + const index = match.index ?? 0; + pushTextSegment(segments, plainText.slice(cursor, index)); + + const label = match[1] ?? ""; + segments.push({ type: "mention", userId: label, label }); + cursor = index + match[0].length; + } + + pushTextSegment(segments, plainText.slice(cursor)); +} + +export function buildCommentBodySegments( + text: string, + mentionIds?: string[], + users?: MentionLookup[], +): CommentBodySegment[] { + if (!text) { + return []; + } + + const segments: CommentBodySegment[] = []; + const encodedPattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g"); + const mentionNameById = resolveMentionNames(mentionIds, users); + const encodedLabels: string[] = []; + for (const match of text.matchAll(new RegExp(MENTION_TOKEN_PATTERN.source, "g"))) { + const label = match[2]?.trim(); + if (label) encodedLabels.push(label); + } + const mentionNames = [...new Set([...mentionNameById.values(), ...encodedLabels])].filter( + Boolean, + ); + let cursor = 0; + + for (const match of text.matchAll(encodedPattern)) { + const index = match.index ?? 0; + const plainBetween = text.slice(cursor, index); + appendPlainMentionMatches(segments, plainBetween, mentionNames); + + const userId = match[1] ?? ""; + const label = match[2] ?? userId; + segments.push({ type: "mention", userId, label }); + cursor = index + match[0].length; + } + + const trailingPlain = text.slice(cursor); + appendPlainMentionMatches(segments, trailingPlain, mentionNames); + + return segments.length > 0 ? segments : [{ type: "text", value: text }]; +} + +export type ActiveMentionQuery = { + start: number; + query: string; +}; + +export function findActiveMentionQuery(text: string, cursor: number): ActiveMentionQuery | null { + const beforeCursor = text.slice(0, cursor); + const atIndex = beforeCursor.lastIndexOf("@"); + + if (atIndex < 0) { + return null; + } + + const prefix = beforeCursor.slice(0, atIndex); + const lastPrefixChar = prefix.length > 0 ? prefix[prefix.length - 1] : ""; + if (prefix.length > 0 && !/\s/.test(lastPrefixChar)) { + return null; + } + + const query = beforeCursor.slice(atIndex + 1); + if (/[\n\r\t[\]]/.test(query)) { + return null; + } + + return { start: atIndex, query }; +} + +export function filterMentionCandidates( + candidates: MentionLookup[], + query: string, +): MentionLookup[] { + const normalized = query.trim().toLowerCase(); + const filtered = normalized + ? candidates.filter((candidate) => candidate.name.toLowerCase().includes(normalized)) + : candidates; + + return filtered.slice(0, 8); +} + +export function insertMentionToken( + text: string, + queryStart: number, + cursor: number, + user: MentionLookup, +): { nextText: string; nextCursor: number } { + const token = `${encodeMentionToken(user.id, user.name)} `; + const nextText = `${text.slice(0, queryStart)}${token}${text.slice(cursor)}`; + const nextCursor = queryStart + token.length; + + return { nextText, nextCursor }; +} diff --git a/src/test/components/common/comment-mention-composer.test.tsx b/src/test/components/common/comment-mention-composer.test.tsx new file mode 100644 index 00000000..a732af50 --- /dev/null +++ b/src/test/components/common/comment-mention-composer.test.tsx @@ -0,0 +1,28 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it } from "vitest"; + +import { CommentMentionComposer } from "@/components/common/comment-mention-composer"; +import { renderWithProviders } from "@/test/test-utils"; + +const candidates = [{ id: "1", name: "Alice Dispatcher" }]; + +function MentionComposerHarness() { + const [value, setValue] = useState(""); + + return ; +} + +describe("CommentMentionComposer", () => { + it("keeps the suggestion menu visible after typing @", async () => { + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + + await user.type(screen.getByRole("textbox"), "@"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "@Alice Dispatcher" })).toBeVisible(); + }); + }); +}); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 9466e814..af480a06 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -380,6 +380,15 @@ describe("workOrdersApi.createComment", () => { expect(comment.authorId).toBe("guid"); expect(comment.createdDate).toBe("2026-06-01T12:00:00.0000000Z"); }); + + it("POSTs mentions when provided", async () => { + await workOrdersApi.createComment(55, "Ping @[u1:Jane]", ["u1"]); + + expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.comments(55), { + text: "Ping @[u1:Jane]", + mentions: ["u1"], + }); + }); }); describe("workOrdersApi.updateComment", () => { diff --git a/src/test/domain/work-orders/utils/comment-mentions.test.ts b/src/test/domain/work-orders/utils/comment-mentions.test.ts new file mode 100644 index 00000000..95e03ebc --- /dev/null +++ b/src/test/domain/work-orders/utils/comment-mentions.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + buildCommentBodySegments, + encodeMentionToken, + extractMentionIds, + filterMentionCandidates, + findActiveMentionQuery, + insertMentionToken, + toDisplayText, +} from "@/domain/work-orders/utils/comment-mentions"; + +describe("comment-mentions", () => { + it("encodes and extracts mention ids from tokens", () => { + const token = encodeMentionToken("user-1", "Jane Doe"); + const text = `Please review ${token} today`; + + expect(text).toBe("Please review @[user-1:Jane Doe] today"); + expect(extractMentionIds(text)).toEqual(["user-1"]); + expect(toDisplayText(text)).toBe("Please review @Jane Doe today"); + }); + + it("builds highlight segments from encoded tokens", () => { + const text = `Hi ${encodeMentionToken("42", "Alex Smith")}!`; + + expect(buildCommentBodySegments(text)).toEqual([ + { type: "text", value: "Hi " }, + { type: "mention", userId: "42", label: "Alex Smith" }, + { type: "text", value: "!" }, + ]); + }); + + it("highlights plain @names when response mentions are provided", () => { + const text = "Ping @Jane Doe about this"; + + expect( + buildCommentBodySegments(text, ["user-1"], [{ id: "user-1", name: "Jane Doe" }]), + ).toEqual([ + { type: "text", value: "Ping " }, + { type: "mention", userId: "Jane Doe", label: "Jane Doe" }, + { type: "text", value: " about this" }, + ]); + }); + + it("does not highlight casual @names from the full user roster without mentions", () => { + const text = "See @Jane Doe later"; + + expect(buildCommentBodySegments(text, undefined, [{ id: "user-1", name: "Jane Doe" }])).toEqual( + [{ type: "text", value: "See @Jane Doe later" }], + ); + }); + + it("highlights plain @names before encoded tokens when mentioned", () => { + const encoded = encodeMentionToken("2", "Bob"); + const text = `Hi @Jane Doe and ${encoded}`; + + expect( + buildCommentBodySegments( + text, + ["user-1", "2"], + [ + { id: "user-1", name: "Jane Doe" }, + { id: "2", name: "Bob" }, + ], + ), + ).toEqual([ + { type: "text", value: "Hi " }, + { type: "mention", userId: "Jane Doe", label: "Jane Doe" }, + { type: "text", value: " and " }, + { type: "mention", userId: "2", label: "Bob" }, + ]); + }); + + it("does not highlight shorter mention names as prefixes of longer @words", () => { + const text = "Check @Annual maintenance with @Ann"; + + expect(buildCommentBodySegments(text, ["user-ann"], [{ id: "user-ann", name: "Ann" }])).toEqual( + [ + { type: "text", value: "Check @Annual maintenance with " }, + { type: "mention", userId: "Ann", label: "Ann" }, + ], + ); + }); + + it("finds active mention query at cursor", () => { + const text = "Hello @ja"; + const query = findActiveMentionQuery(text, text.length); + + expect(query).toEqual({ start: 6, query: "ja" }); + expect(findActiveMentionQuery("email@test.com", 13)).toBeNull(); + }); + + it("filters mention candidates by query", () => { + const candidates = [ + { id: "1", name: "Jane Doe" }, + { id: "2", name: "John Smith" }, + ]; + + expect(filterMentionCandidates(candidates, "jo")).toEqual([{ id: "2", name: "John Smith" }]); + }); + + it("inserts encoded mention token replacing active query", () => { + const text = "Hello @ja"; + const activeQuery = findActiveMentionQuery(text, text.length); + + expect(activeQuery).not.toBeNull(); + if (!activeQuery) { + return; + } + + const { nextText, nextCursor } = insertMentionToken(text, activeQuery.start, text.length, { + id: "1", + name: "Jane Doe", + }); + + expect(nextText).toBe("Hello @[1:Jane Doe] "); + expect(nextCursor).toBe(nextText.length); + }); +}); diff --git a/tmp/pr-descriptions/pr9-comment-mentions.md b/tmp/pr-descriptions/pr9-comment-mentions.md new file mode 100644 index 00000000..a3c588db --- /dev/null +++ b/tmp/pr-descriptions/pr9-comment-mentions.md @@ -0,0 +1,41 @@ +# PR9 — Work order comment @mentions + +## Jira + +- [SH-217](https://luby-us.atlassian.net/browse/SH-217) — FE @mention autocomplete / chips +- Related BE: [SH-75](https://luby-us.atlassian.net/browse/SH-75) + +## QA residual + +- Parcial / Blocked-BE for notification + structured `mentions[]` confirm; token chip polish = P1. + +## Summary + +- Adds `@` autocomplete to the internal comments composer on work order detail (`/workorders/:id`). +- Encodes selected users as `@[userId:Display Name]` tokens in comment text so mentions stay readable when the backend ignores the optional payload field. +- Sends `mentions: string[]` on `POST workorders/{id}/comments` when the composer contains encoded tokens. +- Highlights mentions in the comment thread using encoded tokens and/or `mentions` returned by the API. + +## Out of scope + +- Notification Center deep-linking for mention events. +- Backend notification triggers beyond the FE payload. + +## BE contract + +See `docs/work-orders/pending-be-contract.md` (Comments section): + +| Field | Status | +| ---------------------------------------- | -------- | +| POST `{ text, mentions?: string[] }` | FE ready | +| Response includes mentions for highlight | FE ready | + +Until BE confirms, encoded tokens in `text` preserve highlight behavior client-side. + +## Test plan + +- [ ] Open a work order detail → Internal tab → type `@` and pick a dispatcher from the list. +- [ ] Submit comment; verify POST body includes `text` with encoded token and `mentions` array. +- [ ] Reload detail; verify mention is highlighted in the thread (token and/or API `mentions`). +- [ ] Type `@` mid-word (e.g. email) — autocomplete should not open. +- [ ] Run `npm run verify`.