Add interactive status and assigned to dropdowns on work order list

- Status column shows colored badge with dropdown to change status
- Assigned To column shows dropdown to reassign work orders
- Both use portal-rendered menus and update rows locally
- Fix grid cell vertical alignment to center content
- Both changes logged in audit trail
This commit is contained in:
Adam Moussa 2026-04-16 19:04:59 -04:00
parent 168383b0ee
commit 29fe1173d2
4 changed files with 266 additions and 5 deletions

View file

@ -79,6 +79,16 @@ export const addComment = async (workOrderId, text, commentType = "internal") =>
});
};
export const changeWorkOrderAssignment = async (id, assignTo) => {
return post(`${API_URL}/WorkOrder/ChangeAssignment?id=${id}&assignTo=${encodeURIComponent(assignTo || "")}`);
};
export const fetchUsers = async () => {
const { get: apiGet } = await import("../../apiUtil");
const data = await apiGet(`${API_URL}/User`);
return (data || []).map((u) => ({ id: u.id ?? u.Id, name: u.name ?? u.Name ?? u.userName ?? "" }));
};
export const changeWorkOrderStatus = async (id, status) => {
return post(`${API_URL}/WorkOrder/ChangeStatus?id=${id}&status=${encodeURIComponent(status)}`);
};

View file

@ -1,13 +1,175 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import SharedTable from "../../../components/SharedTable";
import { fetchUsers, changeWorkOrderAssignment, changeWorkOrderStatus } from "../api";
import { toast } from "react-toastify";
const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"];
function StatusDropdown({ value, rowId, onUpdate }) {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
const menuRef = useRef(null);
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
const statusClass = (value || "").toLowerCase().replace(/\s+/g, "-");
useEffect(() => {
const handleClick = (e) => {
if (btnRef.current?.contains(e.target)) return;
if (menuRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleOpen = () => {
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.left });
}
setOpen(!open);
};
const handleSelect = async (newStatus) => {
if (newStatus === value) { setOpen(false); return; }
setOpen(false);
try {
await changeWorkOrderStatus(rowId, newStatus);
onUpdate(rowId, newStatus);
toast.success(`Status changed to ${newStatus}`);
} catch (err) {
toast.error("Failed to update status");
}
};
return (
<>
<button ref={btnRef} className={`woStatusBadge ${statusClass} woStatusClickable`} onClick={handleOpen} style={{ border: "none" }}>
{value || "Open"} <span className="woStatusCaret">&#9662;</span>
</button>
{open && ReactDOM.createPortal(
<div ref={menuRef} className="assignDropdownMenu" style={{ top: menuPos.top, left: menuPos.left }}>
{STATUS_OPTIONS.map((s) => (
<button
key={s}
className={`assignDropdownOption ${s === value ? "active" : ""}`}
onClick={() => handleSelect(s)}
>
<span className={`woStatusDot ${s.toLowerCase().replace(/\s+/g, "-")}`} style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", marginRight: 8 }} />
{s}
</button>
))}
</div>,
document.body
)}
</>
);
}
function AssignDropdown({ value, rowId, onUpdate }) {
const [open, setOpen] = useState(false);
const [users, setUsers] = useState([]);
const [loaded, setLoaded] = useState(false);
const btnRef = useRef(null);
const menuRef = useRef(null);
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
useEffect(() => {
const handleClick = (e) => {
if (btnRef.current?.contains(e.target)) return;
if (menuRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const handleOpen = async () => {
if (!loaded) {
try {
const data = await fetchUsers();
setUsers(data);
setLoaded(true);
} catch (err) {
toast.error("Failed to load users");
return;
}
}
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.left });
}
setOpen(!open);
};
const handleSelect = async (userId, userName) => {
setOpen(false);
try {
await changeWorkOrderAssignment(rowId, userId);
onUpdate(rowId, userName);
toast.success(`Assigned to ${userName || "Unassigned"}`);
} catch (err) {
toast.error("Failed to update assignment");
}
};
return (
<>
<button ref={btnRef} className="assignDropdownBtn" onClick={handleOpen}>
{value || "Unassigned"} <span className="woStatusCaret">&#9662;</span>
</button>
{open && ReactDOM.createPortal(
<div ref={menuRef} className="assignDropdownMenu" style={{ top: menuPos.top, left: menuPos.left }}>
<button
className={`assignDropdownOption ${!value ? "active" : ""}`}
onClick={() => handleSelect("", "")}
>
Unassigned
</button>
{users.map((u) => (
<button
key={u.id}
className={`assignDropdownOption ${value === u.name ? "active" : ""}`}
onClick={() => handleSelect(u.id, u.name)}
>
{u.name}
</button>
))}
</div>,
document.body
)}
</>
);
}
export default function Table({ loading, error, rows, onEdit, onDelete, page, setPage, pageSize, setPageSize, totalCount, totalPages, onRowUpdate }) {
const [localRows, setLocalRows] = useState(rows);
useEffect(() => {
setLocalRows(rows);
}, [rows]);
const handleAssignUpdate = (rowId, newName) => {
setLocalRows((prev) =>
prev.map((r) => r.id === rowId ? { ...r, assignedTo: newName, employeeName: newName } : r)
);
};
const handleStatusUpdate = (rowId, newStatus) => {
setLocalRows((prev) =>
prev.map((r) => r.id === rowId ? { ...r, status: newStatus } : r)
);
};
export default function Table({ loading, error, rows, onEdit, onDelete, page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "number", label: "WO #", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "title", label: "Title" },
{ key: "location", label: "Location" },
{ key: "priority", label: "Priority" },
{ key: "status", label: "Status" },
{ key: "status", label: "Status", render: (val, row) => (
<StatusDropdown value={val} rowId={row.id} onUpdate={handleStatusUpdate} />
)},
{ key: "createdDate", label: "Created", render: (val) => {
if (!val) return "";
const d = new Date(val.endsWith("Z") ? val : val + "Z");
@ -17,10 +179,12 @@ export default function Table({ loading, error, rows, onEdit, onDelete, page, se
const tz = d.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
return `${date} ${time} ${tz}`;
}},
{ key: "assignedTo", label: "Assigned To" },
{ key: "assignedTo", label: "Assigned To", render: (val, row) => (
<AssignDropdown value={val || row.employeeName} rowId={row.id} onUpdate={handleAssignUpdate} />
)},
];
return (
<SharedTable rows={rows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
<SharedTable rows={localRows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
);
}

View file

@ -238,6 +238,91 @@
border-right: 1px solid #ddd;
}
.assignDropdownBtn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 8px;
font-size: 13px;
color: #333;
cursor: pointer;
white-space: nowrap;
}
.assignDropdownBtn:hover {
border-color: #ccc;
background: #f5f5f5;
}
.assignDropdownMenu {
position: fixed;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
z-index: 99999;
min-width: 180px;
max-height: 250px;
overflow-y: auto;
padding: 4px 0;
}
.assignDropdownOption {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: #fff;
font-size: 13px;
color: #333;
cursor: pointer;
text-align: left;
}
.assignDropdownOption:hover {
background: #f0f0f0;
}
.assignDropdownOption.active {
font-weight: 600;
background: #e6f0ff;
}
.woStatusDot.open { background: #007bff; }
.woStatusDot.in-progress { background: #ffc107; }
.woStatusDot.on-hold { background: #fd7e14; }
.woStatusDot.done { background: #28a745; }
.woStatusDot.cancelled { background: #dc3545; }
.woStatusBadge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: #6c757d;
}
.woStatusBadge.open { background: #007bff; }
.woStatusBadge.in-progress { background: #ffc107; color: #333; }
.woStatusBadge.on-hold { background: #fd7e14; }
.woStatusBadge.done { background: #28a745; }
.woStatusBadge.cancelled { background: #dc3545; }
.woStatusClickable {
cursor: pointer;
}
.woStatusClickable:hover {
filter: brightness(0.9);
}
.woStatusCaret {
font-size: 10px;
margin-left: 4px;
}
/* Table wrapper */
.tableContainer {
background: white;

View file

@ -153,6 +153,8 @@ body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
}
.gridHeader {