mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 00:02:06 +00:00
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:
parent
168383b0ee
commit
29fe1173d2
4 changed files with 266 additions and 5 deletions
|
|
@ -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) => {
|
export const changeWorkOrderStatus = async (id, status) => {
|
||||||
return post(`${API_URL}/WorkOrder/ChangeStatus?id=${id}&status=${encodeURIComponent(status)}`);
|
return post(`${API_URL}/WorkOrder/ChangeStatus?id=${id}&status=${encodeURIComponent(status)}`);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 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">▾</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">▾</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 = [
|
const columns = [
|
||||||
{ key: "number", label: "WO #", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
|
{ 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: "title", label: "Title" },
|
||||||
{ key: "location", label: "Location" },
|
{ key: "location", label: "Location" },
|
||||||
{ key: "priority", label: "Priority" },
|
{ 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) => {
|
{ key: "createdDate", label: "Created", render: (val) => {
|
||||||
if (!val) return "";
|
if (!val) return "";
|
||||||
const d = new Date(val.endsWith("Z") ? val : val + "Z");
|
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();
|
const tz = d.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
|
||||||
return `${date} ${time} ${tz}`;
|
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 (
|
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} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,91 @@
|
||||||
border-right: 1px solid #ddd;
|
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 */
|
/* Table wrapper */
|
||||||
.tableContainer {
|
.tableContainer {
|
||||||
background: white;
|
background: white;
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,8 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gridHeader {
|
.gridHeader {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue