Add WO fields, collapsible sections, assign dropdown, settings page

- New Classification section: Problem, Trade, SubTrade dropdowns (SubTrade filters by Trade)
- New Schedule fields: Scheduled, Completed dates
- New Financial section: Vendor NTE, Customer NTE placeholder
- All sections below Details default collapsed with toggle arrows
- Assigned To on view page is now a clickable dropdown like status
- TT moved to Details section
- Dropdown options settings page at /settings/dropdowns with add/edit/delete
- Settings link in sidebar and user dropdown
This commit is contained in:
Adam Moussa 2026-04-17 10:40:55 -04:00
parent 46bf66a35d
commit feacb72337
10 changed files with 756 additions and 164 deletions

View file

@ -46,6 +46,9 @@ import AssetFormPage from "./pages/assets/form/AssetFormPage";
import CalendarPage from "./pages/calendar/list/CalendarPage";
import EventFormPage from "./pages/calendar/form/EventFormPage";
// SETTINGS
import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage";
// Protected Route Component
function ProtectedRoute({ children }) {
return isAuthenticated() ? children : <Navigate to="/login" replace />;
@ -121,6 +124,9 @@ export default function App() {
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/calendar/new" element={<EventFormPage />} />
<Route path="/calendar/edit/:id" element={<EventFormPage />} />
{/* SETTINGS */}
<Route path="/settings/dropdowns" element={<DropdownOptionsPage />} />
</Routes>
</div>
</div>

View file

@ -13,6 +13,7 @@ import {
FaClipboardList,
FaCubes,
FaMapMarkerAlt,
FaCog,
} from "react-icons/fa";
const navItems = [
@ -28,6 +29,7 @@ const navItems = [
{ label: "PM Schedules", icon: <FaClipboardList />, path: "/pmschedules" },
{ label: "Assets", icon: <FaCubes />, path: "/assets" },
{ label: "Locations", icon: <FaMapMarkerAlt />, path: "/locations" },
{ label: "Settings", icon: <FaCog />, path: "/settings/dropdowns" },
];
export default function Sidebar() {

View file

@ -107,8 +107,8 @@ function UserDropdown({ currentUser, onLogout, onNavigate }) {
<button className="userDropdownItem" onClick={() => handleNav("/profile")}>
<i className="fa fa-user"></i> My Profile
</button>
<button className="userDropdownItem" onClick={() => handleNav("/settings")}>
<i className="fa fa-cog"></i> Account Settings
<button className="userDropdownItem" onClick={() => handleNav("/settings/dropdowns")}>
<i className="fa fa-cog"></i> Dropdown Options
</button>
<button className="userDropdownItem" onClick={() => handleNav("/change-password")}>
<i className="fa fa-lock"></i> Change Password

View file

@ -0,0 +1,182 @@
.dropdownSettingsPage {
padding: 20px;
}
.dropdownSettingsHeader {
margin-bottom: 24px;
}
.dropdownSettingsHeader h2 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
.dropdownSettingsHeader p {
margin: 0;
font-size: 13px;
color: #999;
}
.dropdownSettingsLayout {
display: flex;
gap: 0;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
min-height: 400px;
}
.dropdownSettingsSidebar {
width: 180px;
background: #f8f9fa;
border-right: 1px solid #ddd;
flex-shrink: 0;
}
.dropdownSettingsTab {
display: block;
width: 100%;
padding: 12px 16px;
border: none;
background: none;
text-align: left;
font-size: 13px;
font-weight: 500;
color: #666;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.dropdownSettingsTab:hover {
background: #eee;
}
.dropdownSettingsTab.active {
background: #fff;
color: #007bff;
font-weight: 600;
border-right: 2px solid #007bff;
}
.dropdownSettingsContent {
flex: 1;
padding: 16px;
}
.dropdownSettingsAddRow {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.dropdownSettingsInput {
flex: 1;
height: 36px;
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
}
.dropdownSettingsSelect {
height: 36px;
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
min-width: 140px;
}
.dropdownSettingsAddBtn {
height: 36px;
padding: 6px 16px;
background: #007bff;
color: #fff;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
.dropdownSettingsAddBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdownSettingsAddBtn:hover:not(:disabled) {
background: #0069d9;
}
.dropdownSettingsList {
display: flex;
flex-direction: column;
gap: 4px;
}
.dropdownSettingsItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 1px solid #eee;
border-radius: 4px;
font-size: 13px;
}
.dropdownSettingsItem:hover {
background: #f9f9f9;
}
.dropdownSettingsItemValue {
display: flex;
align-items: center;
gap: 8px;
}
.dropdownSettingsParentTag {
font-size: 11px;
padding: 2px 8px;
background: #e9ecef;
border-radius: 10px;
color: #666;
}
.dropdownSettingsItemActions {
display: flex;
gap: 4px;
}
.dropdownSettingsBtn {
padding: 4px 10px;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
background: #f0f0f0;
color: #333;
}
.dropdownSettingsBtn.edit:hover { background: #e0e0e0; }
.dropdownSettingsBtn.save { background: #28a745; color: #fff; }
.dropdownSettingsBtn.cancel { background: #6c757d; color: #fff; }
.dropdownSettingsBtn.delete { color: #dc3545; }
.dropdownSettingsBtn.delete:hover { background: #fff5f5; }
.dropdownSettingsEditInput {
flex: 1;
height: 30px;
padding: 4px 8px;
border: 1px solid #007bff;
border-radius: 3px;
font-size: 13px;
}
.dropdownSettingsLoading,
.dropdownSettingsEmpty {
padding: 40px;
text-align: center;
color: #999;
font-size: 13px;
}

View file

@ -0,0 +1,192 @@
import React, { useState, useEffect } from "react";
import { get, post, put, deleteItem } from "../../apiUtil";
import { API_URL } from "../../constants";
import { toast } from "react-toastify";
import "./DropdownOptionsPage.css";
const CATEGORIES = [
{ key: "Trade", label: "Trades", hasChildren: true },
{ key: "SubTrade", label: "Sub-Trades", parent: "Trade" },
{ key: "Problem", label: "Problems", hasChildren: false },
];
export default function DropdownOptionsPage() {
const [activeCategory, setActiveCategory] = useState("Trade");
const [options, setOptions] = useState([]);
const [parentOptions, setParentOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [newValue, setNewValue] = useState("");
const [newParent, setNewParent] = useState("");
const [editingId, setEditingId] = useState(null);
const [editValue, setEditValue] = useState("");
const categoryConfig = CATEGORIES.find((c) => c.key === activeCategory);
const loadOptions = async () => {
setLoading(true);
try {
const data = await get(`${API_URL}/DropdownOptions/ByCategory/${activeCategory}`);
setOptions(data || []);
} catch {
toast.error("Failed to load options");
} finally {
setLoading(false);
}
};
const loadParents = async () => {
if (categoryConfig?.parent) {
const data = await get(`${API_URL}/DropdownOptions/ByCategory/${categoryConfig.parent}`);
setParentOptions(data || []);
}
};
useEffect(() => {
loadOptions();
loadParents();
setNewValue("");
setNewParent("");
setEditingId(null);
}, [activeCategory]);
const handleAdd = async () => {
if (!newValue.trim()) return;
try {
const body = {
category: activeCategory,
value: newValue.trim(),
parentValue: categoryConfig?.parent ? newParent || null : null,
sortOrder: options.length,
isActive: true,
};
await post(`${API_URL}/DropdownOptions`, body);
setNewValue("");
setNewParent("");
loadOptions();
toast.success("Option added");
} catch (err) {
toast.error("Failed to add option");
}
};
const handleDelete = async (id) => {
try {
await deleteItem(`${API_URL}/DropdownOptions/${id}`);
loadOptions();
toast.success("Option deleted");
} catch {
toast.error("Failed to delete");
}
};
const handleEditSave = async (item) => {
try {
await put(`${API_URL}/DropdownOptions/${item.id}`, {
...item,
value: editValue.trim(),
});
setEditingId(null);
loadOptions();
toast.success("Option updated");
} catch {
toast.error("Failed to update");
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
};
return (
<section className="dropdownSettingsPage">
<div className="dropdownSettingsHeader">
<h2>Dropdown Options</h2>
<p>Manage configurable dropdown values for work orders</p>
</div>
<div className="dropdownSettingsLayout">
<div className="dropdownSettingsSidebar">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
className={`dropdownSettingsTab ${activeCategory === cat.key ? "active" : ""}`}
onClick={() => setActiveCategory(cat.key)}
>
{cat.label}
</button>
))}
</div>
<div className="dropdownSettingsContent">
<div className="dropdownSettingsAddRow">
{categoryConfig?.parent && (
<select
className="dropdownSettingsSelect"
value={newParent}
onChange={(e) => setNewParent(e.target.value)}
>
<option value="">Parent: All</option>
{parentOptions.map((p) => (
<option key={p.id} value={p.value}>{p.value}</option>
))}
</select>
)}
<input
className="dropdownSettingsInput"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Add new ${categoryConfig?.label?.slice(0, -1) || "option"}...`}
/>
<button className="dropdownSettingsAddBtn" onClick={handleAdd} disabled={!newValue.trim()}>
Add
</button>
</div>
{loading ? (
<div className="dropdownSettingsLoading">Loading...</div>
) : options.length === 0 ? (
<div className="dropdownSettingsEmpty">No options configured yet</div>
) : (
<div className="dropdownSettingsList">
{options.map((item) => (
<div key={item.id} className="dropdownSettingsItem">
{editingId === item.id ? (
<input
className="dropdownSettingsEditInput"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleEditSave(item); if (e.key === "Escape") setEditingId(null); }}
autoFocus
/>
) : (
<div className="dropdownSettingsItemValue">
<span>{item.value}</span>
{item.parentValue && <span className="dropdownSettingsParentTag">{item.parentValue}</span>}
</div>
)}
<div className="dropdownSettingsItemActions">
{editingId === item.id ? (
<>
<button className="dropdownSettingsBtn save" onClick={() => handleEditSave(item)}>Save</button>
<button className="dropdownSettingsBtn cancel" onClick={() => setEditingId(null)}>Cancel</button>
</>
) : (
<>
<button className="dropdownSettingsBtn edit" onClick={() => { setEditingId(item.id); setEditValue(item.value); }}>Edit</button>
<button className="dropdownSettingsBtn delete" onClick={() => handleDelete(item.id)}>Delete</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
);
}

View file

@ -32,6 +32,13 @@ export const getWorkOrderById = async (id) => {
status: wo.status ?? "Open",
po: wo.po ?? "",
tt: wo.tt ?? "",
problem: wo.problem ?? "",
trade: wo.trade ?? "",
subTrade: wo.subTrade ?? "",
vendorNTE: wo.vendorNTE != null ? String(wo.vendorNTE) : "",
scheduledDate: wo.scheduledDate ? wo.scheduledDate.split("T")[0] : "",
completedDate: wo.completedDate ? wo.completedDate.split("T")[0] : "",
source: wo.source ?? "",
comments: wo.comments ?? [],
auditLog: wo.auditLog ?? [],
};
@ -64,6 +71,13 @@ export const editWorkOrder = async (workOrder) => {
Priority: workOrder.priority || null,
Status: workOrder.status,
LocationId: workOrder.locationId ? parseInt(workOrder.locationId) : null,
Problem: workOrder.problem || null,
Trade: workOrder.trade || null,
SubTrade: workOrder.subTrade || null,
VendorNTE: workOrder.vendorNTE ? parseFloat(workOrder.vendorNTE) : null,
ScheduledDate: workOrder.scheduledDate || null,
CompletedDate: workOrder.completedDate || null,
Source: workOrder.source || null,
});
} catch (error) {
console.error("Error updating work order:", error);
@ -89,6 +103,13 @@ export const fetchUsers = async () => {
return (data || []).map((u) => ({ id: u.id ?? u.Id, name: u.name ?? u.Name ?? u.userName ?? "" }));
};
export const fetchDropdownOptions = async (category, parent) => {
const { get: apiGet } = await import("../../apiUtil");
let url = `${API_URL}/DropdownOptions/ByCategory/${encodeURIComponent(category)}`;
if (parent) url += `?parent=${encodeURIComponent(parent)}`;
return (await apiGet(url)) || [];
};
export const changeWorkOrderStatus = async (id, status) => {
return post(`${API_URL}/WorkOrder/ChangeStatus?id=${id}&status=${encodeURIComponent(status)}`);
};

View file

@ -164,6 +164,10 @@
padding-left: 15px;
}
.col-sm-3 {
width: 25%;
}
.col-sm-4 {
width: 33.33333333%;
}

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById, addWorkOrder, editWorkOrder } from "../api";
import { getWorkOrderById, addWorkOrder, editWorkOrder, fetchDropdownOptions } from "../api";
import { get } from "../../../apiUtil";
import { API_URL } from "../../../constants";
import "./WorkOrderFormPage.css";
@ -20,21 +20,41 @@ export default function WorkOrderFormPage() {
status: "Open",
createdDate: new Date().toISOString().split("T")[0],
dueDate: "",
scheduledDate: "",
completedDate: "",
assignedTo: "",
description: "",
po: "",
tt: "",
problem: "",
trade: "",
subTrade: "",
vendorNTE: "",
source: "",
});
const [loading, setLoading] = useState(false);
const [locations, setLocations] = useState([]);
const [problems, setProblems] = useState([]);
const [trades, setTrades] = useState([]);
const [subTrades, setSubTrades] = useState([]);
useEffect(() => {
get(`${API_URL}/Contact/GetLocations`)
.then((data) => setLocations(data || []))
.catch(() => {});
fetchDropdownOptions("Problem").then(setProblems).catch(() => {});
fetchDropdownOptions("Trade").then(setTrades).catch(() => {});
}, []);
useEffect(() => {
if (formData.trade) {
fetchDropdownOptions("SubTrade", formData.trade).then(setSubTrades).catch(() => {});
} else {
setSubTrades([]);
}
}, [formData.trade]);
useEffect(() => {
if (isEdit && id) {
const loadWorkOrder = async () => {
@ -60,10 +80,11 @@ export default function WorkOrderFormPage() {
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
setFormData(prev => {
const updated = { ...prev, [name]: value };
if (name === "trade") updated.subTrade = "";
return updated;
});
};
const handleSave = async (e) => {
@ -106,7 +127,6 @@ export default function WorkOrderFormPage() {
<div className="container">
<form onSubmit={handleSave}>
{/* Page Header */}
<div className="form-group">
<div className="header">
<h3 className="heading thin">
@ -119,57 +139,29 @@ export default function WorkOrderFormPage() {
</div>
</div>
{/* Work Order Details Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Work Order Details</strong>
</h5>
<h5 className="heading text-uppercase"><strong>Work Order Details</strong></h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="workOrderNumber">Customer WO</label>
<input
className="form-control"
type="text"
id="workOrderNumber"
name="workOrderNumber"
value={formData.workOrderNumber}
onChange={handleChange}
placeholder="Customer work order number"
/>
<input className="form-control" type="text" id="workOrderNumber" name="workOrderNumber" value={formData.workOrderNumber} onChange={handleChange} placeholder="Customer work order number" />
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="title">Title</label>
<input
className="form-control"
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Work order title"
required
/>
<input className="form-control" type="text" id="title" name="title" value={formData.title} onChange={handleChange} placeholder="Work order title" required />
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="locationId">Location</label>
<select
className="form-control"
id="locationId"
name="locationId"
value={formData.locationId}
onChange={handleChange}
required
>
<select className="form-control" id="locationId" name="locationId" value={formData.locationId} onChange={handleChange} required>
<option value="">Select Location</option>
{locations.map((loc) => (
<option key={loc.id} value={loc.id}>{loc.name}</option>
@ -179,73 +171,63 @@ export default function WorkOrderFormPage() {
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="assignedTo">Assigned To</label>
<select
className="form-control"
id="assignedTo"
name="assignedTo"
value={formData.assignedTo}
onChange={handleChange}
>
<option value="">Select Employee</option>
<option value="John Anderson">John Anderson</option>
<option value="Sarah Martinez">Sarah Martinez</option>
<option value="Mike Johnson">Mike Johnson</option>
<label htmlFor="source">Source</label>
<input className="form-control" type="text" id="source" name="source" value={formData.source} onChange={handleChange} placeholder="e.g., Amazon APM, Manual" />
</div>
</div>
</div>
</div>
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase"><strong>Classification</strong></h5>
</div>
<div className="row">
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="problem">Problem</label>
<select className="form-control" id="problem" name="problem" value={formData.problem} onChange={handleChange}>
<option value="">Select Problem</option>
{problems.map((p) => (
<option key={p.id} value={p.value}>{p.value}</option>
))}
</select>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="trade">Trade</label>
<select className="form-control" id="trade" name="trade" value={formData.trade} onChange={handleChange}>
<option value="">Select Trade</option>
{trades.map((t) => (
<option key={t.id} value={t.value}>{t.value}</option>
))}
</select>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="subTrade">Sub-Trade</label>
<select className="form-control" id="subTrade" name="subTrade" value={formData.subTrade} onChange={handleChange} disabled={!formData.trade}>
<option value="">{formData.trade ? "Select Sub-Trade" : "Select a Trade first"}</option>
{subTrades.map((s) => (
<option key={s.id} value={s.value}>{s.value}</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Schedule & Priority Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Schedule & Priority</strong>
</h5>
<h5 className="heading text-uppercase"><strong>Schedule & Priority</strong></h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="createdDate">Created Date</label>
<input
className="form-control"
type="date"
id="createdDate"
name="createdDate"
value={formData.createdDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="dueDate">Due Date</label>
<input
className="form-control"
type="date"
id="dueDate"
name="dueDate"
value={formData.dueDate}
onChange={handleChange}
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="priority">Priority</label>
<select
className="form-control"
id="priority"
name="priority"
value={formData.priority}
onChange={handleChange}
required
>
<select className="form-control" id="priority" name="priority" value={formData.priority} onChange={handleChange} required>
<option value="">Select Priority</option>
<option value="Sev 1">Sev 1</option>
<option value="Sev 2">Sev 2</option>
@ -255,17 +237,10 @@ export default function WorkOrderFormPage() {
</select>
</div>
</div>
<div className="col-sm-6">
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="status">Status</label>
<select
className="form-control"
id="status"
name="status"
value={formData.status}
onChange={handleChange}
required
>
<select className="form-control" id="status" name="status" value={formData.status} onChange={handleChange} required>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="On Hold">On Hold</option>
@ -274,81 +249,86 @@ export default function WorkOrderFormPage() {
</select>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="po">Customer PO</label>
<input
className="form-control"
type="text"
id="po"
name="po"
value={formData.po}
onChange={handleChange}
placeholder="Purchase order number"
/>
<label htmlFor="assignedTo">Assigned To</label>
<input className="form-control" type="text" id="assignedTo" name="assignedTo" value={formData.assignedTo} onChange={handleChange} placeholder="Assigned to" />
</div>
</div>
<div className="col-sm-6">
</div>
<div className="row">
<div className="col-sm-3">
<div className="form-group">
<label htmlFor="tt">TT</label>
<input
className="form-control"
type="text"
id="tt"
name="tt"
value={formData.tt}
onChange={handleChange}
placeholder="Trouble ticket number"
/>
<label className="required-field" htmlFor="createdDate">Created</label>
<input className="form-control" type="date" id="createdDate" name="createdDate" value={formData.createdDate} onChange={handleChange} required />
</div>
</div>
<div className="col-sm-3">
<div className="form-group">
<label htmlFor="dueDate">Due Date</label>
<input className="form-control" type="date" id="dueDate" name="dueDate" value={formData.dueDate} onChange={handleChange} />
</div>
</div>
<div className="col-sm-3">
<div className="form-group">
<label htmlFor="scheduledDate">Scheduled</label>
<input className="form-control" type="date" id="scheduledDate" name="scheduledDate" value={formData.scheduledDate} onChange={handleChange} />
</div>
</div>
<div className="col-sm-3">
<div className="form-group">
<label htmlFor="completedDate">Completed</label>
<input className="form-control" type="date" id="completedDate" name="completedDate" value={formData.completedDate} onChange={handleChange} />
</div>
</div>
</div>
</div>
{/* Description Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Description</strong>
</h5>
<h5 className="heading text-uppercase"><strong>Financial</strong></h5>
</div>
<div className="row">
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="vendorNTE">Vendor NTE</label>
<input className="form-control" type="number" id="vendorNTE" name="vendorNTE" value={formData.vendorNTE} onChange={handleChange} placeholder="0.00" step="0.01" min="0" />
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="po">Customer PO</label>
<input className="form-control" type="text" id="po" name="po" value={formData.po} onChange={handleChange} placeholder="Purchase order number" />
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="tt">TT</label>
<input className="form-control" type="text" id="tt" name="tt" value={formData.tt} onChange={handleChange} placeholder="Trouble ticket number" />
</div>
</div>
</div>
</div>
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase"><strong>Description</strong></h5>
</div>
<div className="row">
<div className="col-sm-12">
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
className="form-control"
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="10"
style={{ resize: "none" }}
placeholder="Detailed description of the work to be performed"
/>
<textarea className="form-control" id="description" name="description" value={formData.description} onChange={handleChange} rows="10" style={{ resize: "none" }} placeholder="Detailed description of the work to be performed" />
</div>
</div>
</div>
</div>
{/* Form Actions */}
<div className="form-actions">
<button
type="button"
className="btn btn-default"
onClick={handleCancel}
disabled={loading}
>
<button type="button" className="btn btn-default" onClick={handleCancel} disabled={loading}>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>

View file

@ -120,6 +120,76 @@
border-bottom: 1px solid #eee;
}
.woAssignBtn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 8px;
font-size: 14px;
color: #333;
cursor: pointer;
white-space: nowrap;
}
.woAssignBtn:hover {
border-color: #ccc;
background: #f5f5f5;
}
.woAssignMenu {
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;
}
.woAssignOption {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: #fff;
font-size: 13px;
color: #333;
cursor: pointer;
text-align: left;
}
.woAssignOption:hover {
background: #f0f0f0;
}
.woAssignOption.active {
font-weight: 600;
background: #e6f0ff;
}
.woSectionToggle {
cursor: pointer;
user-select: none;
}
.woSectionToggle:hover {
color: #007bff;
}
.woSectionArrow {
display: inline-block;
margin-right: 6px;
transition: transform 0.15s;
font-size: 12px;
}
.woSectionArrow.open {
transform: rotate(90deg);
}
.woFieldGrid {
display: grid;
grid-template-columns: 1fr 1fr;
@ -152,6 +222,11 @@
white-space: pre-wrap;
}
.woFieldPlaceholder {
color: #ccc;
font-style: italic;
}
.woTimeline {
display: flex;
flex-direction: column;

View file

@ -2,9 +2,82 @@ 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 } from "../api";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment } from "../api";
import "./WorkOrderViewPage.css";
function AssignDropdown({ value, onAssign }) {
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 { 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);
onAssign(userId, userName);
};
return (
<>
<button ref={btnRef} className="woAssignBtn" onClick={handleOpen}>
{value || "Unassigned"} <span className="woStatusCaret">&#9662;</span>
</button>
{open && ReactDOM.createPortal(
<div ref={menuRef} className="woAssignMenu" style={{ top: menuPos.top, left: menuPos.left }}>
<button className={`woAssignOption ${!value ? "active" : ""}`} onClick={() => handleSelect("", "")}>
Unassigned
</button>
{users.map((u) => (
<button key={u.id} className={`woAssignOption ${value === u.name ? "active" : ""}`} onClick={() => handleSelect(u.id, u.name)}>
{u.name}
</button>
))}
</div>,
document.body
)}
</>
);
}
function CollapsibleSection({ title, defaultOpen = false, children }) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="woViewSection">
<h4 className="woSectionToggle" onClick={() => setOpen(!open)}>
<span className={`woSectionArrow ${open ? "open" : ""}`}>&#9656;</span>
{title}
</h4>
{open && children}
</div>
);
}
function formatDateTime(value) {
const d = new Date(value.endsWith("Z") ? value : value + "Z");
if (isNaN(d)) return value;
@ -328,8 +401,49 @@ export default function WorkOrderViewPage() {
</div>
<div className="woField">
<span className="woFieldLabel">Assigned To</span>
<span className="woFieldValue">{wo.assignedTo || "—"}</span>
<span className="woFieldValue">
<AssignDropdown
value={wo.assignedTo}
onAssign={async (userId, userName) => {
try {
await changeWorkOrderAssignment(wo.id, userId);
setWo((prev) => ({ ...prev, assignedTo: userName }));
toast.success(`Assigned to ${userName || "Unassigned"}`);
} catch { toast.error("Failed to update assignment"); }
}}
/>
</span>
</div>
<div className="woField">
<span className="woFieldLabel">Source</span>
<span className="woFieldValue">{wo.source || "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">TT</span>
<span className="woFieldValue">{wo.tt || "—"}</span>
</div>
</div>
</div>
<CollapsibleSection title="Classification">
<div className="woFieldGrid">
<div className="woField">
<span className="woFieldLabel">Problem</span>
<span className="woFieldValue">{wo.problem || "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">Trade</span>
<span className="woFieldValue">{wo.trade || "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">Sub-Trade</span>
<span className="woFieldValue">{wo.subTrade || "—"}</span>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Schedule">
<div className="woFieldGrid">
<div className="woField">
<span className="woFieldLabel">Created</span>
<span className="woFieldValue">
@ -341,20 +455,36 @@ export default function WorkOrderViewPage() {
<span className="woFieldValue">{wo.dueDate || "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">PO</span>
<span className="woFieldLabel">Scheduled</span>
<span className="woFieldValue">{wo.scheduledDate || "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">Completed</span>
<span className="woFieldValue">{wo.completedDate || "—"}</span>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Financial">
<div className="woFieldGrid">
<div className="woField">
<span className="woFieldLabel">Vendor NTE</span>
<span className="woFieldValue">{wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">Customer PO</span>
<span className="woFieldValue">{wo.po || "—"}</span>
</div>
<div className="woField">
<span className="woFieldLabel">TT</span>
<span className="woFieldValue">{wo.tt || "—"}</span>
<span className="woFieldLabel">Customer NTE</span>
<span className="woFieldValue woFieldPlaceholder">Not connected</span>
</div>
</div>
</div>
</CollapsibleSection>
<div className="woViewSection">
<h4>Description</h4>
<CollapsibleSection title="Description">
<p className="woDescription">{wo.description || "No description provided."}</p>
</div>
</CollapsibleSection>
<div className="woViewSection">
<h4>Activity</h4>