Add checklist UI to dispatch workflow

- Task List Template picker in dispatch modal
- Checklist section in dispatch detail modal with toggleable items
- Shows completion count, completed by user name + timestamp
- Strikethrough styling on completed items
- fetchTaskTemplates, updateChecklistItem, addChecklistItem API functions
This commit is contained in:
Adam Moussa 2026-04-17 15:28:52 -04:00
parent 714376c61b
commit 62bc2f058c
3 changed files with 102 additions and 1 deletions

View file

@ -121,6 +121,19 @@ export const fetchVendorsDropdown = async (trade, siteZip) => {
return (await apiGet(`${API_URL}/Vendor/Dropdown${qs ? `?${qs}` : ""}`)) || [];
};
export const fetchTaskTemplates = async () => {
const { get: apiGet } = await import("../../apiUtil");
return (await apiGet(`${API_URL}/TaskListTemplate`)) || [];
};
export const updateChecklistItem = async (id, isCompleted, completedBy) => {
return post(`${API_URL}/WorkOrder/UpdateChecklistItem`, { Id: id, IsCompleted: isCompleted, CompletedBy: completedBy });
};
export const addChecklistItem = async (dispatchId, itemText, workOrderId) => {
return post(`${API_URL}/WorkOrder/AddChecklistItem`, { DispatchId: dispatchId, ItemText: itemText, WorkOrderId: workOrderId });
};
export const getDispatchById = async (id) => {
const { get: apiGet } = await import("../../apiUtil");
return apiGet(`${API_URL}/WorkOrder/GetDispatch/${id}`);

View file

@ -734,6 +734,48 @@
padding-top: 4px;
}
.dispatchChecklist {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
}
.dispatchChecklistItem {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
}
.dispatchChecklistItem:hover {
background: #f8f9fa;
}
.dispatchChecklistItem.completed .dispatchChecklistText {
text-decoration: line-through;
color: #999;
}
.dispatchChecklistText {
flex: 1;
color: #333;
}
.dispatchChecklistDone {
font-size: 11px;
color: #28a745;
}
.dispatchChecklistItem input[type="checkbox"] {
width: 16px;
height: 16px;
flex-shrink: 0;
cursor: pointer;
}
.dispatchThreadContainer {
display: flex;
flex-direction: column;

View file

@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor, getDispatchById, updateDispatch, addDispatchComment } from "../api";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor, getDispatchById, updateDispatch, addDispatchComment, fetchTaskTemplates, updateChecklistItem, addChecklistItem } from "../api";
import { getCurrentUser } from "../../../services/authService";
import "./WorkOrderViewPage.css";
function AssignDropdown({ value, onAssign }) {
@ -329,6 +330,38 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
</div>
<div className="dispatchDetailSection">
{dispatch.checklistItems && dispatch.checklistItems.length > 0 && (
<>
<h4>Checklist ({dispatch.checklistItems.filter(i => i.isCompleted).length}/{dispatch.checklistItems.length})</h4>
<div className="dispatchChecklist">
{dispatch.checklistItems.map((item) => (
<div key={item.id} className={`dispatchChecklistItem ${item.isCompleted ? "completed" : ""}`}>
<input
type="checkbox"
checked={item.isCompleted}
onChange={async () => {
const currentUser = getCurrentUser();
const userName = currentUser?.fullname || "User";
await updateChecklistItem(item.id, !item.isCompleted, userName);
setDispatch((prev) => ({
...prev,
checklistItems: prev.checklistItems.map((ci) =>
ci.id === item.id ? { ...ci, isCompleted: !ci.isCompleted, completedBy: !ci.isCompleted ? userName : null, completedAt: !ci.isCompleted ? new Date().toISOString() : null } : ci
),
}));
}}
/>
<span className="dispatchChecklistText">{item.itemText}</span>
{item.isCompleted && (
<span className="dispatchChecklistDone">
{item.completedBy}{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
</span>
)}
</div>
))}
</div>
</>
)}
<h4>Vendor Communication</h4>
<div className="dispatchThreadContainer">
<div className="dispatchThreadScroll">
@ -386,6 +419,8 @@ function DispatchModal({ wo, onClose, onDispatched }) {
const [description, setDescription] = useState(wo.description || "");
const [vendorSearch, setVendorSearch] = useState("");
const [sending, setSending] = useState(false);
const [taskTemplates, setTaskTemplates] = useState([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [loaded, setLoaded] = useState(false);
useEffect(() => {
@ -393,6 +428,7 @@ function DispatchModal({ wo, onClose, onDispatched }) {
setVendors(data);
setLoaded(true);
}).catch(() => setLoaded(true));
fetchTaskTemplates().then(setTaskTemplates).catch(() => {});
}, [wo.trade]);
const selectVendor = (id) => {
@ -414,6 +450,7 @@ function DispatchModal({ wo, onClose, onDispatched }) {
NTEAmount: nteAmount ? parseFloat(nteAmount) : null,
ScheduledDate: scheduledDate || null,
Description: description,
TaskListTemplateId: selectedTemplateId ? parseInt(selectedTemplateId) : null,
});
toast.success("Dispatched successfully");
onDispatched();
@ -481,6 +518,15 @@ function DispatchModal({ wo, onClose, onDispatched }) {
<input type="date" className="form-control" value={scheduledDate} onChange={(e) => setScheduledDate(e.target.value)} />
</div>
</div>
<div className="woDispatchField">
<label>Task List Template</label>
<select className="form-control" value={selectedTemplateId} onChange={(e) => setSelectedTemplateId(e.target.value)}>
<option value="">No checklist</option>
{taskTemplates.map((t) => (
<option key={t.id} value={t.id}>{t.name} ({t.items?.length || 0} items)</option>
))}
</select>
</div>
<div className="woDispatchField">
<label>Description</label>
<textarea className="form-control" value={description} onChange={(e) => setDescription(e.target.value)} rows="4" style={{ resize: "none" }} />