diff --git a/src/pages/workorders/api.js b/src/pages/workorders/api.js index ebdfbb8b..c069fb05 100644 --- a/src/pages/workorders/api.js +++ b/src/pages/workorders/api.js @@ -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)}`); }; diff --git a/src/pages/workorders/list/Table.js b/src/pages/workorders/list/Table.js index 01c9c8c8..9f4319a1 100644 --- a/src/pages/workorders/list/Table.js +++ b/src/pages/workorders/list/Table.js @@ -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 ( + <> + + {open && ReactDOM.createPortal( +
+ {STATUS_OPTIONS.map((s) => ( + + ))} +
, + 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 ( + <> + + {open && ReactDOM.createPortal( +
+ + {users.map((u) => ( + + ))} +
, + 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) => { e.preventDefault(); onEdit && onEdit(row); }}>{val} }, { key: "title", label: "Title" }, { key: "location", label: "Location" }, { key: "priority", label: "Priority" }, - { key: "status", label: "Status" }, + { key: "status", label: "Status", render: (val, row) => ( + + )}, { 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) => ( + + )}, ]; return ( - + ); } diff --git a/src/pages/workorders/list/styles.css b/src/pages/workorders/list/styles.css index 33dfaf54..54af4470 100644 --- a/src/pages/workorders/list/styles.css +++ b/src/pages/workorders/list/styles.css @@ -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; diff --git a/src/site.css b/src/site.css index 652b1572..7144924f 100644 --- a/src/site.css +++ b/src/site.css @@ -153,6 +153,8 @@ body { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; + align-items: center; } .gridHeader {