feat(work-orders): add mentions in work-order comments composer

This commit is contained in:
arthur.bassi 2026-08-03 08:55:17 -03:00
parent a0214cc01f
commit 0aced50621
15 changed files with 626 additions and 72 deletions

View file

@ -31,9 +31,12 @@ import {
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
@ -45,6 +48,7 @@ import {
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
import { extractMentionIds } from "@/domain/work-orders/utils/comment-mentions";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
const STATUS_COLORS: Record<
@ -85,57 +89,6 @@ function isVendorEditorValid(editor: VendorEditorState): boolean {
);
}
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
[...comments].sort(
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
),
[comments],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No comments yet.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((comment) => (
<Box key={String(comment.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
<Typography variant="body2">{comment.commenttext}</Typography>
</Box>
))}
</Stack>
);
}
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
@ -220,6 +173,18 @@ export default function WorkOrderViewPage() {
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const mentionUsers = useMemo(
() => users.map((user) => ({ id: user.id, name: user.name })),
[users],
);
const customerTimelineItems = useMemo(
() => mapWorkOrderCommentsToTimeline(customerComments),
[customerComments],
);
const internalTimelineItems = useMemo(
() => mapWorkOrderCommentsToTimeline(internalComments),
[internalComments],
);
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
@ -230,8 +195,15 @@ export default function WorkOrderViewPage() {
return;
}
const trimmedText = commentText.trim();
const mentions = extractMentionIds(trimmedText);
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{
text: trimmedText,
mentions: mentions.length > 0 ? mentions : undefined,
commentType: activeTab,
},
{ onSuccess: () => setCommentText("") },
);
};
@ -626,18 +598,20 @@ export default function WorkOrderViewPage() {
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "customer" && (
<CommentTimeline items={customerTimelineItems} mentionUsers={mentionUsers} />
)}
{activeTab === "internal" && (
<CommentTimeline items={internalTimelineItems} mentionUsers={mentionUsers} />
)}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
<CommentMentionComposer
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
onChange={setCommentText}
candidates={mentionUsers}
disabled={commentMutation.isPending}
/>
<Button

View file

@ -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 (
<Text variant="description" as="span">
{segments.map((segment, index) => {
if (segment.type === "text") {
return <span key={`text-${index}`}>{segment.value}</span>;
}
return (
<Box
component="span"
key={`mention-${segment.userId}-${index}`}
className="font-semibold text-primary"
>
@{segment.label}
</Box>
);
})}
</Text>
);
}

View file

@ -0,0 +1,173 @@
import { useCallback, 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;
};
export function CommentMentionComposer({
value,
onChange,
candidates,
placeholder = "Type a comment...",
disabled = false,
rows = 2,
onSubmit,
}: CommentMentionComposerProps) {
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(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);
const syncCursor = useCallback(() => {
const element = inputRef.current;
if (!element) {
return;
}
const nextCursor = element.selectionStart ?? value.length;
setCursor(nextCursor);
setMenuOpen(Boolean(findActiveMentionQuery(value, nextCursor)));
setHighlightIndex(0);
}, [value]);
const handleChange = (nextValue: string) => {
onChange(nextValue);
requestAnimationFrame(syncCursor);
};
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<HTMLDivElement>) => {
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 (
<ClickAwayListener onClickAway={() => setMenuOpen(false)}>
<Box
className="relative"
ref={(node: HTMLDivElement | null) => {
setAnchorEl(node);
}}
>
<TextField
inputRef={inputRef}
multiline
rows={rows}
fullWidth
value={value}
onChange={(event) => handleChange(event.target.value)}
onClick={syncCursor}
onKeyUp={syncCursor}
onSelect={syncCursor}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
/>
<Popper open={showMenu} anchorEl={anchorEl} placement="bottom-start" className="z-50">
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
<List dense disablePadding>
{filteredCandidates.map((candidate, index) => (
<ListItemButton
key={String(candidate.id)}
selected={index === highlightIndex}
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyMention(candidate)}
>
@{candidate.name}
</ListItemButton>
))}
</List>
</Paper>
</Popper>
</Box>
</ClickAwayListener>
);
}

View file

@ -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,
}));
}

View file

@ -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 <Typography color="text.secondary">{emptyMessage}</Typography>;
return <Text tone="muted">{emptyMessage}</Text>;
}
return (
@ -39,14 +44,14 @@ export function CommentTimeline({
{sorted.map((item) => (
<Box key={String(item.id)} className="rounded border border-border p-3">
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
<Typography variant="caption" className="font-semibold">
<Text variant="caption" className="font-semibold">
{item.author}
</Typography>
<Typography variant="caption" color="text.secondary">
</Text>
<Text variant="caption" tone="muted">
{item.createdAt ? formatDateTime(item.createdAt) : ""}
</Typography>
</Text>
</Stack>
<Typography variant="body2">{item.body}</Typography>
<CommentBodyWithMentions text={item.body} mentions={item.mentions} users={mentionUsers} />
</Box>
))}
</Stack>

View file

@ -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<unknown> {
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<WorkOrderComment> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
createComment: async (
workOrderId: string | number,
text: string,
mentions?: string[],
): Promise<WorkOrderComment> => {
const payload: CreateWorkOrderCommentPayload = { text };
if (mentions && mentions.length > 0) {
payload.mentions = mentions.map(String);
}
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), payload);
return mapBoardDetailComment(handleApiResponse(data));
},

View file

@ -61,6 +61,33 @@ function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolea
return false;
}
function readStringArray(record: Record<string, unknown>, ...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"),
};
}

View file

@ -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,
};
}

View file

@ -94,6 +94,7 @@ export interface WorkOrderBoardDetailCommentApi {
text?: string | null;
time?: string | null;
documents?: unknown;
mentions?: string[] | null;
}
export interface WorkOrderBoardDetailAuditApi {

View file

@ -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 {

View file

@ -40,6 +40,7 @@ function invalidateBoardDetail(
interface AddWorkOrderCommentInput {
text: string;
mentions?: string[];
commentType?: "customer" | "internal";
}
@ -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);
},

View file

@ -0,0 +1,184 @@
/** 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<string, string> {
const byId = new Map<string, string>();
for (const user of users ?? []) {
byId.set(String(user.id), user.name);
}
for (const mentionId of mentionIds ?? []) {
const id = String(mentionId);
if (!byId.has(id)) {
byId.set(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("|");
const mentionRegex = new RegExp(`@(${namePattern})`, "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");
let cursor = 0;
for (const match of text.matchAll(encodedPattern)) {
const index = match.index ?? 0;
const plainBetween = text.slice(cursor, index);
appendPlainMentionMatches(segments, plainBetween, []);
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);
const mentionNameById = resolveMentionNames(mentionIds, users);
const mentionNames = [...new Set(mentionNameById.values())].filter(Boolean);
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 };
}

View file

@ -381,6 +381,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", () => {

View file

@ -0,0 +1,78 @@
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("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);
});
});

View file

@ -0,0 +1,32 @@
# PR9 — Work order comment @mentions
## 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`.