Add comment input on Internal and Vendor tabs

- Add CommentInput component with textarea and send button
- Enter to send, Shift+Enter for new line
- New comments append to the list immediately without reload
- Fixed 400px height for tab content with scrollable overflow
- Comment input bar pinned at bottom of tab container
- Add addComment API function calling AddCommentJson endpoint
- Auto-scroll to newest comment
This commit is contained in:
Adam Moussa 2026-04-16 18:22:34 -04:00
parent dc8c8b68f3
commit d3a4bcc984
3 changed files with 174 additions and 36 deletions

View file

@ -1,4 +1,6 @@
import { workOrderApi } from "../../services/api";
import { post } from "../../apiUtil";
import { API_URL } from "../../constants";
export const getWorkOrders = async (params = {}) => {
try {
@ -68,6 +70,14 @@ export const editWorkOrder = async (workOrder) => {
}
};
export const addComment = async (workOrderId, text, commentType = "internal") => {
return post(`${API_URL}/WorkOrder/AddCommentJson`, {
WorkorderId: workOrderId,
Text: text,
CommentType: commentType,
});
};
export const deleteWorkOrder = async (id) => {
try {
await workOrderApi.delete(id);

View file

@ -126,10 +126,23 @@
line-height: 1.5;
}
.woTabContainer {
display: flex;
flex-direction: column;
height: 400px;
border: 1px solid #e5e5e5;
border-radius: 0 0 6px 6px;
}
.woTabScroll {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.woCommentTabs {
display: flex;
gap: 0;
margin-bottom: 16px;
border-bottom: 2px solid #e5e5e5;
}
@ -187,6 +200,37 @@
font-weight: 500;
}
.woCommentInput {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #e5e5e5;
background: #f8f9fa;
flex-shrink: 0;
}
.woCommentTextarea {
flex: 1;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
resize: none;
line-height: 1.4;
}
.woCommentTextarea:focus {
outline: none;
border-color: #007bff;
}
.woCommentSend {
align-self: flex-end;
padding: 8px 16px;
font-size: 13px;
}
.loading {
padding: 40px;
text-align: center;

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById } from "../api";
import { getWorkOrderById, addComment } from "../api";
import "./WorkOrderViewPage.css";
function formatDateTime(value) {
@ -14,54 +14,109 @@ function formatDateTime(value) {
}
function CommentTimeline({ comments }) {
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current?.scrollIntoView();
}, [comments?.length]);
if (!comments || comments.length === 0) {
return <p className="woNoComments">No comments yet.</p>;
return <div className="woTabScroll"><p className="woNoComments">No comments yet.</p></div>;
}
return (
<div className="woTimeline">
{comments.map((c) => (
<div key={c.id} className="woTimelineItem">
<div className="woTimelineHeader">
<span className="woTimelineAuthor">{c.userName || c.commenter || "System"}</span>
<span className="woTimelineDate">
{c.createdDate ? formatDateTime(c.createdDate) : ""}
</span>
<div className="woTabScroll">
<div className="woTimeline">
{comments.map((c) => (
<div key={c.id} className="woTimelineItem">
<div className="woTimelineHeader">
<span className="woTimelineAuthor">{c.userName || c.commenter || "System"}</span>
<span className="woTimelineDate">
{c.createdDate ? formatDateTime(c.createdDate) : ""}
</span>
</div>
<div className="woTimelineBody">{c.commenttext}</div>
</div>
<div className="woTimelineBody">{c.commenttext}</div>
</div>
))}
))}
<div ref={bottomRef} />
</div>
</div>
);
}
function AuditTimeline({ entries }) {
if (!entries || entries.length === 0) {
return <p className="woNoComments">No changes recorded.</p>;
return <div className="woTabScroll"><p className="woNoComments">No changes recorded.</p></div>;
}
return (
<div className="woTimeline">
{entries.map((e) => (
<div key={e.id} className="woTimelineItem woAuditItem">
<div className="woTimelineHeader">
<span className="woTimelineAuthor">{e.userName || "System"}</span>
<span className="woTimelineDate">
{e.createdAt ? formatDateTime(e.createdAt) : ""}
</span>
<div className="woTabScroll">
<div className="woTimeline">
{entries.map((e) => (
<div key={e.id} className="woTimelineItem woAuditItem">
<div className="woTimelineHeader">
<span className="woTimelineAuthor">{e.userName || "System"}</span>
<span className="woTimelineDate">
{e.createdAt ? formatDateTime(e.createdAt) : ""}
</span>
</div>
<div className="woTimelineBody">
<span className="woAuditField">{e.fieldName}</span>
{e.oldValue && <span className="woAuditOld">{e.oldValue}</span>}
<span className="woAuditArrow">→</span>
<span className="woAuditNew">{e.newValue}</span>
</div>
</div>
<div className="woTimelineBody">
<span className="woAuditField">{e.fieldName}</span>
{e.oldValue && <span className="woAuditOld">{e.oldValue}</span>}
<span className="woAuditArrow">→</span>
<span className="woAuditNew">{e.newValue}</span>
</div>
</div>
))}
))}
</div>
</div>
);
}
function CommentInput({ workOrderId, commentType, onCommentAdded }) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!text.trim()) return;
setSending(true);
try {
const newComment = await addComment(workOrderId, text.trim(), commentType);
setText("");
if (onCommentAdded) onCommentAdded(newComment);
} catch (err) {
toast.error("Failed to add comment");
} finally {
setSending(false);
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form className="woCommentInput" onSubmit={handleSubmit}>
<textarea
className="woCommentTextarea"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a comment... (Enter to send, Shift+Enter for new line)"
rows={2}
disabled={sending}
/>
<button className="btn btn-primary woCommentSend" type="submit" disabled={sending || !text.trim()}>
{sending ? "Sending..." : "Send"}
</button>
</form>
);
}
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
@ -83,6 +138,24 @@ export default function WorkOrderViewPage() {
load();
}, [id]);
const handleCommentAdded = (newComment) => {
setWo((prev) => ({
...prev,
comments: [
...prev.comments,
{
id: newComment.id,
createdDate: newComment.createdDate,
commenttext: newComment.commenttext,
commentType: newComment.commentType,
userName: newComment.userName,
commenter: null,
recordType: null,
},
],
}));
};
if (loading) return <div className="woViewPage"><div className="loading">Loading...</div></div>;
if (!wo) return <div className="woViewPage"><div className="loading">Work order not found.</div></div>;
@ -101,6 +174,8 @@ export default function WorkOrderViewPage() {
{ key: "audit", label: "Audit Log", count: auditLog.length },
];
const showInput = activeTab === "internal" || activeTab === "vendor";
return (
<div className="woViewPage">
<div className="panel panel-default panel-connected">
@ -188,10 +263,19 @@ export default function WorkOrderViewPage() {
</button>
))}
</div>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "vendor" && <CommentTimeline comments={vendorComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
<div className="woTabContainer">
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "vendor" && <CommentTimeline comments={vendorComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{showInput && (
<CommentInput
workOrderId={parseInt(id)}
commentType={activeTab}
onCommentAdded={handleCommentAdded}
/>
)}
</div>
</div>
</div>
</div>