fixed style, added new pages, refactored

This commit is contained in:
npalOmega 2026-01-20 15:05:00 -06:00
parent 3f3dcc70f6
commit 933e93c845
55 changed files with 6754 additions and 1131 deletions

3
.gitignore vendored
View file

@ -19,6 +19,9 @@
.env.test.local
.env.production.local
# markdown files
*.md
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -15,6 +15,10 @@
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" integrity="sha512-SfTiTlX6kk+qitfevl/7LibUOeJWlt9rbyDn92a1DqWOw9vWG2MFoays0sgObmWazO5BQPiFucnnEAjpAB+/Sw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
@ -24,7 +28,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Seahaven - Officetrax</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -3,31 +3,35 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import Sidebar from "./components/Sidebar";
import Topbar from "./components/Topbar";
import Dashboard from "./pages/Dashboard";
// CONTACTS
import ContactsList from "./pages/contacts/list/List";
import ContactFormPage from "./pages/contacts/form/ContactFormPage";
import ContactEditPage from "./pages/contacts/form/ContactFormPage";
// ACCOUNTS
import AccountList from "./pages/accounts/list/List";
import AccountForm from "./pages/accounts/form/AccountFormPage";
import AccountEdit from "./pages/accounts/form/AccountFormPage";
import AccountFormPage from "./pages/accounts/form/AccountFormPage";
// EMPLOYEES
import EmployeeList from "./pages/employees/list/List";
import EmployeeForm from "./pages/employees/form/EmployeeFormPage";
import EmployeeEdit from "./pages/employees/form/EmployeeFormPage";
import EmployeeFormPage from "./pages/employees/form/EmployeeFormPage";
// LOCATIONS
import LocationsList from "./pages/locations/list/List";
import LocationFormPage from "./pages/locations/form/LocationFormPage";
// WORK ORDERS
import WorkOrdersList from "./pages/workorders/list/List";
import WorkOrderFormPage from "./pages/workorders/form/WorkOrderFormPage";
// FOLLOW UPS
import FollowUpList from "./pages/followup/list/List";
import FollowUpForm from "./pages/followup/form/FollowUpFormPage";
import FollowUpEdit from "./pages/followup/form/FollowUpFormPage";
import FollowUpFormPage from "./pages/followup/form/FollowUpFormPage";
// PM SCHEDULES
import PMScheduleList from "./pages/PmSchedule/list/List";
import PMScheduleForm from "./pages/PmSchedule/form/PmScheduleFormPage";
import PMScheduleEdit from "./pages/PmSchedule/form/PmScheduleFormPage";
import PMScheduleFormPage from "./pages/PmSchedule/form/PmScheduleFormPage";
export default function App() {
const [navCollapsed, setNavCollapsed] = useState(false);
@ -42,32 +46,43 @@ export default function App() {
<div className="contentWrapper">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* CONTACTS ROUTES */}
{/* CONTACTS */}
<Route path="/contacts" element={<ContactsList />} />
<Route path="/contacts/new" element={<ContactFormPage />} />
<Route path="/contacts/edit/:id" element={<ContactEditPage />} />
<Route path="/contacts/edit/:id" element={<ContactFormPage />} />
{/* ACCOUNTS ROUTES */}
{/* ACCOUNTS */}
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/new" element={<AccountForm />} />
<Route path="/accounts/edit/:id" element={<AccountEdit />} />
<Route path="/accounts/new" element={<AccountFormPage />} />
<Route path="/accounts/edit/:id" element={<AccountFormPage />} />
{/* EMPLOYEES ROUTES */}
{/* EMPLOYEES */}
<Route path="/employees" element={<EmployeeList />} />
<Route path="/employees/new" element={<EmployeeForm />} />
<Route path="/employees/edit/:id" element={<EmployeeEdit />} />
<Route path="/employees/new" element={<EmployeeFormPage />} />
<Route path="/employees/edit/:id" element={<EmployeeFormPage />} />
{/* LOCATIONS */}
<Route path="/locations" element={<LocationsList />} />
<Route path="/locations/new" element={<LocationFormPage />} />
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
{/* WORK ORDERS */}
<Route path="/workorders" element={<WorkOrdersList />} />
<Route path="/workorders/new" element={<WorkOrderFormPage />} />
<Route path="/workorders/edit/:id" element={<WorkOrderFormPage />} />
{/* FOLLOW UPS */}
<Route path="/followups" element={<FollowUpList />} />
<Route path="/followups/new" element={<FollowUpForm />} />
<Route path="/followups/edit/:id" element={<FollowUpEdit />} />
<Route path="/followups/new" element={<FollowUpFormPage />} />
<Route path="/followups/edit/:id" element={<FollowUpFormPage />} />
{/* PM SCHEDULES */}
<Route path="/pmschedules" element={<PMScheduleList />} />
<Route path="/pmschedules/new" element={<PMScheduleForm />} />
<Route path="/pmschedules/edit/:id" element={<PMScheduleEdit />} />
<Route path="/pmschedules/new" element={<PMScheduleFormPage />} />
<Route path="/pmschedules/edit/:id" element={<PMScheduleFormPage />} />
</Routes>
</div>
</div>

View file

@ -0,0 +1,122 @@
/* Action Bar - Top navigation bar above search controls */
.actionBar {
background-color: #f5f5f5;
border-bottom: 1px solid #ddd;
padding: 0;
margin-bottom: 0;
position: relative;
z-index: 10;
}
.actionBarNav {
list-style: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
gap: 0;
}
.actionBarNav > li {
position: relative;
display: inline-block;
}
.actionBarLink {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 15px;
color: #333;
text-decoration: none;
transition: background-color 0.2s;
white-space: nowrap;
}
.actionBarLink:hover {
background-color: #e0e0e0;
color: #000;
text-decoration: none;
}
.actionBarLink i {
font-size: 14px;
}
.actionBarText {
font-size: 13px;
}
/* Dropdown */
.actionBarDropdown {
position: relative;
z-index: 100;
}
.actionBarDropdownMenu {
position: absolute;
top: 100%;
left: 0;
z-index: 1050;
display: block;
min-width: 180px;
padding: 5px 0;
margin: 0;
list-style: none;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
}
.actionBarDropdownMenu li {
display: block;
}
.actionBarDropdownMenu li a {
display: block;
padding: 8px 20px;
color: #333;
text-decoration: none;
font-size: 13px;
white-space: nowrap;
}
.actionBarDropdownMenu li a:hover {
background-color: #f5f5f5;
color: #000;
}
.actionBarDropdownMenu li.divider {
height: 1px;
margin: 5px 0;
overflow: hidden;
background-color: #e5e5e5;
}
.countLabel {
color: #999;
font-size: 12px;
}
.pull-right {
float: right;
margin-left: 10px;
}
/* Hide text on small screens */
@media (max-width: 768px) {
.actionBarText {
display: none;
}
}
/* Close dropdowns when clicking outside */
.actionBarDropdown.open .actionBarDropdownMenu {
display: block;
}
.actionBarDropdown:not(.open) .actionBarDropdownMenu {
display: none;
}

164
src/components/ActionBar.js Normal file
View file

@ -0,0 +1,164 @@
import React, { useState } from "react";
import "./ActionBar.css";
export default function ActionBar({
moduleName = "Items",
moduleIcon = "fa-list",
onNew,
onBulkEdit,
onImportExport,
onSearch,
selectedCount = 0
}) {
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [moduleOpen, setModuleOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState("All");
const handleWorkspaceSelect = (workspace) => {
setSelectedWorkspace(workspace);
setWorkspaceOpen(false);
};
const toggleWorkspace = (e) => {
e.preventDefault();
e.stopPropagation();
setWorkspaceOpen(!workspaceOpen);
setModuleOpen(false);
setSearchOpen(false);
};
const toggleModule = (e) => {
e.preventDefault();
e.stopPropagation();
setModuleOpen(!moduleOpen);
setWorkspaceOpen(false);
setSearchOpen(false);
};
const toggleSearch = (e) => {
e.preventDefault();
e.stopPropagation();
setSearchOpen(!searchOpen);
setWorkspaceOpen(false);
setModuleOpen(false);
};
// Close dropdowns when clicking outside
React.useEffect(() => {
const handleClickOutside = () => {
setWorkspaceOpen(false);
setModuleOpen(false);
setSearchOpen(false);
};
if (workspaceOpen || moduleOpen || searchOpen) {
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [workspaceOpen, moduleOpen, searchOpen]);
return (
<div className="actionBar">
<ul className="actionBarNav">
{/* Settings/Filter Button */}
<li>
<a href="#" className="actionBarLink" title="Settings">
<i className="fa fa-filter"></i>
</a>
</li>
{/* Workspace Selector */}
<li className={`actionBarDropdown ${workspaceOpen ? 'open' : ''}`}>
<a
href="#"
className="actionBarLink"
onClick={toggleWorkspace}
title="Change your workspace"
>
<i className="fa fa-inbox"></i>
<span className="actionBarText">{selectedWorkspace}</span>
<i className="fa fa-angle-down"></i>
</a>
{workspaceOpen && (
<ul className="actionBarDropdownMenu">
<li><a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("All"); }}>All</a></li>
<li><a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("Everyone else"); }}>Everyone else</a></li>
<li><a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("Only Me"); }}>Only Me</a></li>
<li className="divider"></li>
<li><a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("Custom"); }}>Specific people...</a></li>
</ul>
)}
</li>
{/* Module Actions (New, Edit, Import/Export) */}
<li className={`actionBarDropdown ${moduleOpen ? 'open' : ''}`}>
<a
href="#"
className="actionBarLink"
onClick={toggleModule}
title={moduleName}
>
<i className={`fa ${moduleIcon}`}></i>
<span className="actionBarText">{moduleName}</span>
<i className="fa fa-angle-down"></i>
</a>
{moduleOpen && (
<ul className="actionBarDropdownMenu">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); setModuleOpen(false); onNew && onNew(); }}>
New
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); setModuleOpen(false); onBulkEdit && onBulkEdit(); }}>
Edit <span className="countLabel">({selectedCount})</span>
</a>
</li>
<li className="divider"></li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); }}>
Import/Export <i className="fa fa-angle-right pull-right"></i>
</a>
</li>
</ul>
)}
</li>
{/* Search & Report */}
<li className={`actionBarDropdown ${searchOpen ? 'open' : ''}`}>
<a
href="#"
className="actionBarLink"
onClick={toggleSearch}
title="Search & Report"
>
<i className="fa fa-search"></i>
<span className="actionBarText">Search</span>
<i className="fa fa-angle-down"></i>
</a>
{searchOpen && (
<ul className="actionBarDropdownMenu">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); setSearchOpen(false); onSearch && onSearch('smart'); }}>
Smart Search
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); setSearchOpen(false); onSearch && onSearch('open'); }}>
Open
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); }}>
Recent <i className="fa fa-angle-right pull-right"></i>
</a>
</li>
</ul>
)}
</li>
</ul>
</div>
);
}

View file

@ -0,0 +1,141 @@
import React from "react";
import { toast } from "react-toastify";
export default function DeleteModal({
isOpen,
onClose,
onConfirm,
title = "Delete Confirmation",
message = "Are you sure you want to delete this item?",
loading = false
}) {
if (!isOpen) return null;
return (
<div style={styles.overlay}>
<div style={styles.modal}>
<div style={styles.header}>
<h3 style={styles.title}>{title}</h3>
<button
onClick={onClose}
style={styles.closeBtn}
disabled={loading}
>
✕
</button>
</div>
<div style={styles.body}>
<p style={styles.message}>{message}</p>
</div>
<div style={styles.actions}>
<button
onClick={onClose}
style={styles.cancelBtn}
disabled={loading}
>
Cancel
</button>
<button
onClick={onConfirm}
style={styles.deleteBtn}
disabled={loading}
>
{loading ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
);
}
const styles = {
overlay: {
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
},
modal: {
backgroundColor: "#fff",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "400px",
width: "90%",
overflow: "hidden",
},
header: {
backgroundColor: "#f8f9fa",
padding: "16px 20px",
borderBottom: "1px solid #ddd",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
title: {
margin: 0,
fontSize: "18px",
fontWeight: 600,
color: "#333",
},
closeBtn: {
background: "none",
border: "none",
fontSize: "24px",
cursor: "pointer",
color: "#666",
padding: "0",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
body: {
padding: "20px",
},
message: {
margin: 0,
fontSize: "14px",
color: "#666",
lineHeight: "1.5",
},
actions: {
display: "flex",
gap: "10px",
padding: "16px 20px",
borderTop: "1px solid #ddd",
backgroundColor: "#f8f9fa",
},
cancelBtn: {
flex: 1,
padding: "8px 16px",
fontSize: "14px",
fontWeight: 500,
border: "1px solid #ddd",
borderRadius: "4px",
backgroundColor: "#fff",
color: "#333",
cursor: "pointer",
transition: "all 0.2s",
},
deleteBtn: {
flex: 1,
padding: "8px 16px",
fontSize: "14px",
fontWeight: 500,
border: "none",
borderRadius: "4px",
backgroundColor: "#dc3545",
color: "#fff",
cursor: "pointer",
transition: "all 0.2s",
},
};

View file

@ -0,0 +1,121 @@
import React from "react";
const SharedTable = ({
rows = [],
columns = [],
onEdit,
onDelete,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
error
}) => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (loading) return <div className="loading">Loading...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="tableContainer">
<div className="tableResponsive">
<table className="dataTable">
<thead>
<tr>
<th style={{ width: "1%" }}>
<input type="checkbox" />
</th>
{columns.map((col) => (
<th key={col.key} style={col.style}>
{col.label}
</th>
))}
<th style={{ width: "1%" }}>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={row.id || idx}>
<td>
<input type="checkbox" />
</td>
{columns.map((col) => (
<td key={col.key}>
{col.render
? col.render(row[col.key], row)
: row[col.key]}
</td>
))}
<td>
<div className="actionButtons">
{onEdit && (
<button
className="btnIcon edit"
title="Edit"
onClick={() => onEdit(row)}
>
✎
</button>
)}
{onDelete && (
<button
className="btnIcon delete"
title="Delete"
onClick={() => onDelete(row)}
>
🗑
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="paginationContainer">
<div className="paginationLeft">
<label>
Records per page:
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
style={{ marginLeft: "10px" }}
>
<option>12</option>
<option>24</option>
<option>48</option>
<option>96</option>
</select>
</label>
</div>
<div className="paginationRight">
<span>
Showing {Math.min(page * pageSize, totalCount)} of {totalCount}
</span>
<div className="pagination">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
← Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
>
Next →
</button>
</div>
</div>
</div>
</div>
);
};
export default SharedTable;

View file

@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import "./index.css";
import "./site.css";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(

116
src/pages/Dashboard.css Normal file
View file

@ -0,0 +1,116 @@
/* Dashboard Styles */
.dashboardPage {
padding: 20px;
}
.pageTitle {
font-size: 24px;
font-weight: 600;
margin-bottom: 20px;
color: #333;
}
.kpiContainer {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.kpiCard {
padding: 20px;
border-radius: 4px;
color: white;
display: flex;
align-items: center;
gap: 15px;
min-height: 100px;
}
.bg-success { background-color: #28a745; }
.bg-primary { background-color: #007bff; }
.bg-info { background-color: #17a2b8; }
.bg-warning { background-color: #ffc107; color: #333; }
.bg-danger { background-color: #dc3545; }
.kpiIcon {
font-size: 32px;
min-width: 50px;
}
.kpiContent h5 {
margin: 0 0 10px 0;
font-size: 16px;
font-weight: 600;
}
.kpiContent p {
margin: 0;
font-size: 14px;
opacity: 0.9;
}
.kpiStat {
font-size: 24px;
font-weight: bold;
}
.card {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
margin-bottom: 20px;
}
.mt-4 { margin-top: 30px; }
.cardHeader {
padding: 20px;
border-bottom: 1px solid #ddd;
}
.cardHeader h3 {
margin: 0 0 5px 0;
font-size: 18px;
color: #333;
}
.cardHeader p {
margin: 0;
font-size: 13px;
color: #666;
}
.tableWrapper {
padding: 20px;
}
.dashTable {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.dashTable th {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
}
.dashTable td {
border: 1px solid #ddd;
padding: 10px 12px;
color: #333;
}
.dashTable tbody tr:hover {
background: #f9f9f9;
}
.text-right {
text-align: right;
}

109
src/pages/Dashboard.js Normal file
View file

@ -0,0 +1,109 @@
import React from "react";
import "./Dashboard.css";
const Dashboard = () => {
const kpis = [
{ title: "Create a Work Order", icon: "✓", color: "success", desc: "Report a problem you need fixed" },
{ title: "Open Work Orders", icon: "📂", color: "primary", stat: "885" },
{ title: "Not Dispatched", icon: "⏱", color: "info", stat: "503" },
{ title: "Completed Orders", icon: "📋", color: "warning", stat: "4,088" },
];
const priorities = [
{ priority: "5", total: "665", "0-14": "83", "15-29": "79", "30-59": "62", "60+": "441" },
{ priority: "4", total: "327", "0-14": "4", "15-29": "19", "30-59": "3", "60+": "301" },
{ priority: "3", total: "295", "0-14": "69", "15-29": "64", "30-59": "42", "60+": "120" },
{ priority: "2", total: "95", "0-14": "26", "15-29": "38", "30-59": "11", "60+": "20" },
];
const invoices = [
{ name: "Draft", count: 0, amount: "0.00" },
{ name: "Challenged", count: 0, amount: "0.00" },
{ name: "Pending Approval", count: 0, amount: "0.00" },
{ name: "Approved", count: 0, amount: "0.00" },
{ name: "Paid", count: 0, amount: "0.00" },
];
return (
<section className="dashboardPage">
<div className="pageTitle">Dashboard</div>
{/* KPI Cards */}
<div className="kpiContainer">
{kpis.map((kpi, idx) => (
<div key={idx} className={`kpiCard bg-${kpi.color}`}>
<div className="kpiIcon">{kpi.icon}</div>
<div className="kpiContent">
<h5>{kpi.title}</h5>
{kpi.stat && <div className="kpiStat">{kpi.stat}</div>}
{kpi.desc && <p>{kpi.desc}</p>}
</div>
</div>
))}
</div>
{/* Priority Analysis */}
<div className="card mt-4">
<div className="cardHeader">
<h3>Priority Analysis</h3>
<p>Age of open Work Orders by Priority</p>
</div>
<div className="tableWrapper">
<table className="dashTable">
<thead>
<tr>
<th>Priority</th>
<th className="text-right">Total</th>
<th className="text-right">0-14 Days</th>
<th className="text-right">15-29 Days</th>
<th className="text-right">30-59 Days</th>
<th className="text-right">60+ Days</th>
</tr>
</thead>
<tbody>
{priorities.map((row, idx) => (
<tr key={idx}>
<td>{row.priority}</td>
<td className="text-right">{row.total}</td>
<td className="text-right">{row["0-14"]}</td>
<td className="text-right">{row["15-29"]}</td>
<td className="text-right">{row["30-59"]}</td>
<td className="text-right">{row["60+"]}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Invoice Analysis */}
<div className="card mt-4">
<div className="cardHeader">
<h3>Invoice Analysis</h3>
</div>
<div className="tableWrapper">
<table className="dashTable">
<thead>
<tr>
<th>Status</th>
<th className="text-right">Count</th>
<th className="text-right">Amount</th>
</tr>
</thead>
<tbody>
{invoices.map((inv, idx) => (
<tr key={idx}>
<td>{inv.name}</td>
<td className="text-right">{inv.count}</td>
<td className="text-right">${inv.amount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
);
};
export default Dashboard;

View file

@ -0,0 +1,67 @@
// Mock PM schedules data
const mockPMSchedules = [
{
id: 1,
asset: "HVAC-001",
assetName: "Building A HVAC System",
frequency: "Quarterly",
lastServiceDate: "2024-01-15",
nextServiceDate: "2024-04-15",
status: "Active",
assignedTo: "John Anderson",
notes: "Quarterly maintenance check",
},
{
id: 2,
asset: "ELEC-002",
assetName: "Main Electrical Panel",
frequency: "Annual",
lastServiceDate: "2023-12-01",
nextServiceDate: "2024-12-01",
status: "Active",
assignedTo: "Sarah Martinez",
notes: "Annual electrical inspection",
},
{
id: 3,
asset: "PLUMB-001",
assetName: "Building B Plumbing System",
frequency: "Semi-Annual",
lastServiceDate: "2024-01-10",
nextServiceDate: "2024-07-10",
status: "Active",
assignedTo: "Mike Johnson",
notes: "Bi-annual plumbing inspection",
},
];
export const getPMSchedules = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockPMSchedules];
};
export const getPMScheduleById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockPMSchedules.find(p => p.id === parseInt(id));
};
export const addPMSchedule = async (pmSchedule) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newPMSchedule = { id: mockPMSchedules.length + 1, ...pmSchedule };
mockPMSchedules.push(newPMSchedule);
return newPMSchedule;
};
export const editPMSchedule = async (pmSchedule) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockPMSchedules.findIndex(p => p.id === pmSchedule.id);
if (idx >= 0) mockPMSchedules[idx] = pmSchedule;
return pmSchedule;
};
export const deletePMSchedule = async (id) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockPMSchedules.findIndex(p => p.id === id);
if (idx >= 0) mockPMSchedules.splice(idx, 1);
return true;
};

View file

@ -1,114 +1,235 @@
// pages/pmschedules/form/PmScheduleFormPage.jsx
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { addPmSchedule, editPmSchedule, getPmScheduleById } from "../new/api";
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import "./ContactFormPage.css";
import { getPMScheduleById, addPMSchedule, editPMSchedule } from "../api";
export default function PmScheduleFormPage() {
const { id } = useParams();
const isEdit = !!id;
const navigate = useNavigate();
const isEdit = !!id;
const [formData, setFormData] = useState({
asset: "",
assetName: "",
frequency: "Quarterly",
lastServiceDate: "",
nextServiceDate: "",
status: "Active",
assignedTo: "",
notes: "",
});
const [model, setModel] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isEdit) load();
}, [id]);
const load = async () => {
const res = await getPmScheduleById(id);
setModel(res.Data);
};
if (isEdit && id) {
const loadPMSchedule = async () => {
try {
const pm = await getPMScheduleById(id);
if (pm) {
setFormData(pm);
}
} catch (err) {
console.error("Failed to load PM schedule", err);
toast.error("Failed to load PM schedule");
}
};
loadPMSchedule();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value } = e.target;
setModel(prev => ({ ...prev, [name]: value }));
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const submit = async (e) => {
const handleSave = async (e) => {
e.preventDefault();
if (!model.Name) {
toast.error("Name is required");
return;
}
setLoading(true);
try {
if (isEdit) {
await editPmSchedule({ ...model, Id: id });
toast.success("PM Schedule updated");
await editPMSchedule(formData);
toast.success("PM Schedule updated successfully!");
} else {
await addPmSchedule(model);
toast.success("PM Schedule created");
await addPMSchedule(formData);
toast.success("PM Schedule created successfully!");
}
navigate("/pm-schedules");
navigate("/pmschedules");
} catch (err) {
console.error(err);
toast.error("Failed to save PM Schedule");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/pmschedules");
};
return (
<div className="contactFormWrapper">
<div className="formCard">
<div className="formHeader">
<h2>{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}</h2>
</div>
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}
</div>
<form onSubmit={submit}>
<h5 className="sectionTitle">General Details</h5>
<div className="contactFormBody">
<div className="formGroup">
<label className="formLabel">Name *</label>
<input
className="formControl"
name="Name"
value={model.Name || ""}
onChange={handleChange}
/>
<form onSubmit={handleSave} className="formWrapper">
<div className="card formCard">
<div className="formBody">
{/* ASSET INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Asset Information</div>
<div className="formRow">
<div className="formGroup">
<label>Asset ID *</label>
<input
type="text"
name="asset"
value={formData.asset}
onChange={handleChange}
required
placeholder="e.g., HVAC-001"
/>
</div>
<div className="formGroup">
<label>Asset Name *</label>
<input
type="text"
name="assetName"
value={formData.assetName}
onChange={handleChange}
required
placeholder="Asset description"
/>
</div>
</div>
</div>
<div className="formGroup">
<label className="formLabel">Trade</label>
<input
className="formControl"
name="Trade"
value={model.Trade || ""}
onChange={handleChange}
/>
{/* SCHEDULE INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Schedule Information</div>
<div className="formRow">
<div className="formGroup">
<label>Frequency *</label>
<select
name="frequency"
value={formData.frequency}
onChange={handleChange}
required
>
<option value="Daily">Daily</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Semi-Annual">Semi-Annual</option>
<option value="Annual">Annual</option>
</select>
</div>
<div className="formGroup">
<label>Last Service Date *</label>
<input
type="date"
name="lastServiceDate"
value={formData.lastServiceDate}
onChange={handleChange}
required
/>
</div>
<div className="formGroup">
<label>Next Service Date *</label>
<input
type="date"
name="nextServiceDate"
value={formData.nextServiceDate}
onChange={handleChange}
required
/>
</div>
</div>
</div>
<div className="formGroup">
<label className="formLabel">Priority</label>
<input
className="formControl"
name="Priority"
value={model.Priority || ""}
onChange={handleChange}
/>
{/* ASSIGNMENT SECTION */}
<div className="formSection">
<div className="sectionTitle">Assignment</div>
<div className="formRow">
<div className="formGroup">
<label>Assigned To</label>
<select
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>
</select>
</div>
<div className="formGroup">
<label>Status *</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
required
>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Suspended">Suspended</option>
</select>
</div>
</div>
</div>
<div className="formGroup fullWidth">
<label className="formLabel">Additional Details</label>
<textarea
className="formControl"
name="AdditionalDetails"
value={model.AdditionalDetails || ""}
onChange={handleChange}
/>
{/* NOTES SECTION */}
<div className="formSection">
<div className="sectionTitle">Notes</div>
<div className="formRow">
<div className="formGroup fullWidth">
<label>Notes</label>
<textarea
name="notes"
value={formData.notes}
onChange={handleChange}
placeholder="PM schedule notes"
rows="4"
style={{
width: "100%",
padding: "10px",
borderRadius: "4px",
border: "1px solid #ddd",
fontFamily: "inherit",
}}
/>
</div>
</div>
</div>
</div>
{/* ACTION BUTTONS */}
<div className="formActions">
<button className="btnPrimary" disabled={loading}>
{loading ? "Saving..." : "Save PM Schedule"}
<button
type="button"
className="btnSecondary"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btnPrimary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
</div>
</form>
</section>
);
}

View file

@ -1,63 +1,144 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchPmSchedulesPaged } from "./api";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import usePMSchedules from "./usePMSchedules";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import ActionBar from "../../../components/ActionBar";
import { deletePMSchedule } from "../api";
import "./styles.css";
export default function PmScheduleListPage() {
const navigate = useNavigate();
const [data, setData] = useState([]);
const [search, setSearch] = useState("");
const { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload } = usePMSchedules();
const [viewMode, setViewMode] = useState("list");
const [deleteModal, setDeleteModal] = useState({ isOpen: false, item: null, loading: false });
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
useEffect(() => {
load();
}, []);
const handleDeleteClick = (item) => {
setDeleteModal({ isOpen: true, item, loading: false });
};
const load = async () => {
const res = await fetchPmSchedulesPaged({});
setData(res.rows);
const handleDeleteConfirm = async () => {
setDeleteModal(prev => ({ ...prev, loading: true }));
try {
await deletePMSchedule(deleteModal.item.id);
toast.success(`PM Schedule deleted successfully!`);
setDeleteModal({ isOpen: false, item: null, loading: false });
reload();
} catch (err) {
console.error(err);
toast.error("Failed to delete PM schedule");
setDeleteModal(prev => ({ ...prev, loading: false }));
}
};
return (
<div className="pageWrapper">
<div className="pageHeader">
<h2>PM Schedules</h2>
<button
className="btnPrimary"
onClick={() => navigate("/pm-schedules/new")}
>
New PM Schedule
</button>
</div>
<>
<ActionBar
moduleName="PM Schedules"
moduleIcon="fa-calendar-check-o"
onNew={() => navigate("/pmschedules/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Trade</th>
<th>Status</th>
<th>Next Work Order</th>
<th></th>
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.trade}</td>
<td>{row.status}</td>
<td>{row.nextWorkOrderDate}</td>
<td>
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">PM Schedules</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-calendar-check-o fa-stack-1x fa-inverse"></i>
</span>
<span>Search for PM Schedules</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="Asset">
<option>Asset</option>
<option>Frequency</option>
<option>Status</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
onClick={() =>
navigate(`/pm-schedules/${row.id}/edit`)
}
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
Edit
<i className="fa fa-navicon"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
</div>
<Table
loading={loading}
error={err}
rows={rows}
onEdit={(item) => navigate(`/pmschedules/edit/${item.id}`)}
onDelete={handleDeleteClick}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
totalPages={totalPages}
/>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete PM Schedule"
message={`Are you sure you want to delete "${deleteModal.item?.assetName}"? This action cannot be undone.`}
loading={deleteModal.loading}
/>
</section>
</>
);
}

View file

@ -1,37 +1,17 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete, page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "asset", label: "Asset", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "frequency", label: "Frequency" },
{ key: "lastDate", label: "Last Date" },
{ key: "nextDate", label: "Next Date" },
{ key: "status", label: "Status" },
{ key: "assignedTo", label: "Assigned To" },
];
export default function Table({ loading, error, rows, onEdit, onDelete,
page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
return (
<div className="tableWrap">
{error && <div style={{ color: "#b00020" }}>{error}</div>}
<table className="contactTable">
<thead>
<tr>
<th><input type="checkbox" /></th>
<th>Name</th><th>Account</th><th>Title</th><th>Email</th><th>Phone</th><th>Owner</th><th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={8}>Loading…</td></tr> :
rows.length === 0 ? <tr><td colSpan={8}>No records found.</td></tr> :
rows.map(r => (
<tr key={r.id}>
<td><input type="checkbox" /></td>
<td>{r.name}</td>
<td>{r.account}</td>
<td>{r.title}</td>
<td>{r.email}</td>
<td>{r.phoneNumber}</td>
<td>{r.owner}</td>
<td>
<button className="iconSmall" onClick={()=>onEdit(r)}>✎</button>
<button className="iconSmall" onClick={()=>onDelete(r)}>🗑</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<SharedTable rows={rows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
);
}

View file

@ -1,6 +1,231 @@
/* CSS for modals, table, and controls – minimal to work with existing global styles */
.contactTable { width:100%; border-collapse:collapse; }
.contactTable th, .contactTable td { border:1px solid #ddd; padding:8px; }
/* Modal styles for PM Schedules module */
.modalRoot{position:fixed;inset:0;z-index:1000;}
.modalScrim{position:absolute;inset:0;background:rgba(0,0,0,.3);}
.modalCard{position:relative;margin:10% auto;padding:20px;background:#fff;width:400px;}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,41 @@
import { useState, useEffect } from "react";
import { getPMSchedules } from "../api";
const usePMSchedules = () => {
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const reload = async () => {
setLoading(true);
try {
const pmschedules = await getPMSchedules();
let filtered = pmschedules;
if (search) {
filtered = pmschedules.filter((p) =>
p.asset.toLowerCase().includes(search.toLowerCase()) ||
p.frequency.toLowerCase().includes(search.toLowerCase())
);
}
setTotalCount(filtered.length);
const start = (page - 1) * pageSize;
setRows(filtered.slice(start, start + pageSize));
} catch (error) {
setErr(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
export default usePMSchedules;

66
src/pages/accounts/api.js Normal file
View file

@ -0,0 +1,66 @@
// Mock accounts data
const mockAccounts = [
{
id: 1,
name: "Acme Corp",
email: "contact@acmecorp.com",
phone: "(555) 123-4567",
owner: "John Smith",
status: "Active",
},
{
id: 2,
name: "Global Industries",
email: "info@globalind.com",
phone: "(555) 234-5678",
owner: "Sarah Johnson",
status: "Active",
},
{
id: 3,
name: "Tech Solutions LLC",
email: "support@techsol.com",
phone: "(555) 345-6789",
owner: "Mike Chen",
status: "Inactive",
},
{
id: 4,
name: "Premier Services",
email: "hello@premier.com",
phone: "(555) 456-7890",
owner: "Lisa Brown",
status: "Active",
},
];
export const getAccounts = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockAccounts];
};
export const getAccountById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockAccounts.find(a => a.id === parseInt(id));
};
export const addAccount = async (account) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newAccount = { id: mockAccounts.length + 1, ...account };
mockAccounts.push(newAccount);
return newAccount;
};
export const editAccount = async (account) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockAccounts.findIndex(a => a.id === account.id);
if (idx >= 0) mockAccounts[idx] = account;
return account;
};
export const deleteAccount = async (id) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockAccounts.findIndex(a => a.id === id);
if (idx >= 0) mockAccounts.splice(idx, 1);
return true;
};

View file

@ -1,10 +1,9 @@
// src/pages/accounts/form/AccountFormPage.js
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { toast } from "react-toastify";
import "./ContactFormPage.css"; // reuse same CSS
import { addAccount } from "../new/api";
import { editAccount } from "../edit/api";
import { getAccountById, addAccount, editAccount } from "../api";
const AccountFormPage = () => {
const { id } = useParams();
@ -14,6 +13,23 @@ const AccountFormPage = () => {
const [loading, setLoading] = useState(false);
const [account, setAccount] = useState({});
useEffect(() => {
if (isEdit && id) {
const loadAccount = async () => {
try {
const data = await getAccountById(id);
if (data) {
setAccount(data);
}
} catch (err) {
console.error("Failed to load account", err);
toast.error("Failed to load account");
}
};
loadAccount();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setAccount((prev) => ({

View file

@ -1,100 +1,137 @@
import React, { useEffect, useState } from "react";
import { fetchAccountsPaged, deleteAccount } from "./api";
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import useAccounts from "./useAccounts";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import ActionBar from "../../../components/ActionBar";
import { deleteAccount } from "../api";
import "./styles.css";
export default function AccountList() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload } = useAccounts();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, item: null, loading: false });
const [viewMode, setViewMode] = useState("list");
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const load = async () => {
setLoading(true);
const res = await fetchAccountsPaged({ search });
setRows(res?.rows ?? []);
setLoading(false);
const handleDeleteClick = (item) => {
setDeleteModal({ isOpen: true, item, loading: false });
};
useEffect(() => {
load();
}, [search]);
const handleDelete = async (id) => {
if (!window.confirm("Delete this account?")) return;
await deleteAccount(id);
load();
const handleDeleteConfirm = async () => {
setDeleteModal(prev => ({ ...prev, loading: true }));
try {
await deleteAccount(deleteModal.item.id);
toast.success(`Account deleted successfully!`);
setDeleteModal({ isOpen: false, item: null, loading: false });
reload();
} catch (err) {
console.error(err);
toast.error("Failed to delete account");
setDeleteModal(prev => ({ ...prev, loading: false }));
}
};
return (
<section className="contactsPage">
<div className="pageTitle">Accounts</div>
<>
<ActionBar
moduleName="Accounts"
moduleIcon="fa-building"
onNew={() => navigate("/accounts/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<div className="card controlsCard">
<div className="controlsTop">
<div className="leftControls">
<div className="filter">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchBtn">Search!</button>
</div>
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">Accounts</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-building fa-stack-1x fa-inverse"></i>
</span>
<span>Search for Accounts</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="rightControls">
<button
className="iconSmall"
title="New Account"
onClick={() => navigate("/accounts/new")}
>
+
</button>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="Name">
<option>Name</option>
<option>Account Owner</option>
<option>Email</option>
<option>Phone</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
<i className="fa fa-navicon"></i>
</button>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
</div>
{loading ? (
<div style={{ padding: 12 }}>Loading accounts...</div>
) : (
<table className="contactTable">
<thead>
<tr>
<th>Name</th>
<th>Industry</th>
<th>Phone</th>
<th></th>
</tr>
</thead>
<Table loading={loading} error={err} rows={rows}
onEdit={(item) => navigate(`/accounts/edit/${item.id}`)}
onDelete={handleDeleteClick}
page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize}
totalCount={totalCount} totalPages={totalPages} />
<tbody>
{rows.map((x) => (
<tr key={x.id}>
<td>{x.name}</td>
<td>{x.industry}</td>
<td>{x.phoneNumber}</td>
<td className="iconActions">
<button
className="iconBtn"
title="Edit"
onClick={() => navigate(`/accounts/${x.id}/edit`)}
>
✏️
</button>
<button
className="iconBtnDanger"
title="Delete"
onClick={() => handleDelete(x.id)}
>
🗑
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete Account"
message={`Are you sure you want to delete "${deleteModal.item?.name}"? This action cannot be undone.`}
loading={deleteModal.loading}
/>
</section>
</>
);
}

View file

@ -1,37 +1,30 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete,
page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "name", label: "Name", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "email", label: "Email" },
{ key: "phone", label: "Phone" },
{ key: "owner", label: "Owner" },
{ key: "status", label: "Status" },
];
return (
<div className="tableWrap">
{error && <div style={{ color: "#b00020" }}>{error}</div>}
<table className="contactTable">
<thead>
<tr>
<th><input type="checkbox" /></th>
<th>Name</th><th>Account</th><th>Title</th><th>Email</th><th>Phone</th><th>Owner</th><th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={8}>Loading…</td></tr> :
rows.length === 0 ? <tr><td colSpan={8}>No records found.</td></tr> :
rows.map(r => (
<tr key={r.id}>
<td><input type="checkbox" /></td>
<td>{r.name}</td>
<td>{r.account}</td>
<td>{r.title}</td>
<td>{r.email}</td>
<td>{r.phoneNumber}</td>
<td>{r.owner}</td>
<td>
<button className="iconSmall" onClick={()=>onEdit(r)}>✎</button>
<button className="iconSmall" onClick={()=>onDelete(r)}>🗑</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<SharedTable
rows={rows}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
loading={loading}
error={error}
/>
);
}

View file

@ -1,6 +1,231 @@
/* CSS for modals, table, and controls – minimal to work with existing global styles */
.contactTable { width:100%; border-collapse:collapse; }
.contactTable th, .contactTable td { border:1px solid #ddd; padding:8px; }
/* Modal styles for Accounts module */
.modalRoot{position:fixed;inset:0;z-index:1000;}
.modalScrim{position:absolute;inset:0;background:rgba(0,0,0,.3);}
.modalCard{position:relative;margin:10% auto;padding:20px;background:#fff;width:400px;}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { getAccounts } from "../api";
const useAccounts = () => {
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const reload = async () => {
setLoading(true);
try {
const allAccounts = await getAccounts();
let filtered = allAccounts;
if (search) {
filtered = allAccounts.filter((a) =>
a.name.toLowerCase().includes(search.toLowerCase()) ||
a.email.toLowerCase().includes(search.toLowerCase()) ||
a.phone.includes(search)
);
}
setTotalCount(filtered.length);
const start = (page - 1) * pageSize;
setRows(filtered.slice(start, start + pageSize));
} catch (error) {
setErr(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
export default useAccounts;

View file

@ -1,293 +0,0 @@
import React, { useState } from "react";
import { useNavigate, useParams } from "react-router";
import { toast } from "react-toastify";
import "./ContactFormPage.css";
import { addContact } from "../new/api";
import { editContact } from "../edit/api";
const ContactFormPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [loading, setLoading] = useState(false);
const [contact, setContact] = useState({});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setContact((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!contact.FirstName || !contact.LastName) {
toast.error("First and last name are required.");
return;
}
try {
setLoading(true);
// API call based on mode
const payload = {
...contact,
Details: {
Gender: contact.Gender,
DateOfBirth: contact.DateOfBirth,
PrivacySetting: contact.PrivacySetting,
About: contact.About,
Tags: contact.Tags,
},
};
if (isEdit) {
await editContact(payload);
toast.success("Contact updated successfully!");
} else {
await addContact(payload);
toast.success("Contact created successfully!");
}
navigate("/contacts");
} catch (err) {
console.error(err);
toast.error("Something went wrong while saving the contact.");
} finally {
setLoading(false);
}
};
return (
<div className="contactFormWrapper">
<div className="formCard">
<div className="formHeader">
<h2>{isEdit ? "Edit Contact" : "Create a Contact"}</h2>
</div>
<form className="contactForm" onSubmit={handleSubmit} noValidate>
{/* Assignment */}
<section className="section assignment">
<h5 className="sectionTitle">Assignment</h5>
<div className="account-widget">
<div className="account-icon">🔍</div>
<div className="account-info">
<div className="account-title">No Account selected</div>
<div className="account-action">Click here to search Accounts</div>
<div className="account-sub">Use this to identify the company the Contact works for, or is otherwise associated with</div>
</div>
</div>
</section>
<hr />
{/* General Details */}
<section className="section">
<h5 className="sectionTitle">General Details</h5>
<div className="contactFormBody">
<div className="formGroup">
<label className="formLabel">First Name <span className="required">*</span></label>
<input
name="FirstName"
className="formControl"
placeholder="Enter first name"
value={contact.FirstName || ""}
onChange={handleChange}
required
/>
</div>
<div className="formGroup">
<label className="formLabel">Middle Name</label>
<input
name="MiddleName"
className="formControl"
placeholder="Enter middle name"
value={contact.MiddleName || ""}
onChange={handleChange}
/>
</div>
<div className="formGroup">
<label className="formLabel">Last Name <span className="required">*</span></label>
<input
name="LastName"
className="formControl"
placeholder="Enter last name"
value={contact.LastName || ""}
onChange={handleChange}
required
/>
</div>
<div className="formGroup">
<label className="formLabel">Title</label>
<input
name="Title"
className="formControl"
placeholder="Enter a title"
value={contact.Title || ""}
onChange={handleChange}
/>
</div>
<div className="formGroup">
<label className="formLabel">Contact Type</label>
<select
name="ContactTypeID"
className="formControl"
value={contact.ContactTypeID || ""}
onChange={handleChange}
>
<option value="">Select a Contact Type</option>
<option value="5">Corporate</option>
<option value="6">Store Mgr</option>
<option value="7">Facilities</option>
</select>
</div>
<div className="formGroup">
<label className="formLabel">Gender</label>
<select name="Gender" className="formControl" value={contact.Gender || ""} onChange={handleChange}>
<option value="">Select a gender</option>
<option value="0">Male</option>
<option value="1">Female</option>
<option value="2">Not specified</option>
<option value="3">Other</option>
</select>
</div>
<div className="formGroup">
<label className="formLabel">Date of Birth</label>
<input type="date" name="DateOfBirth" className="formControl" value={contact.DateOfBirth || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">Territory <span className="required">*</span></label>
<select name="TerritoryID" className="formControl" value={contact.TerritoryID || "-1"} onChange={handleChange}>
<option value="">Select a Territory</option>
<option value="-1">No Territory</option>
</select>
</div>
<div className="formGroup">
<label className="formLabel">Owner <span className="required">*</span></label>
<select name="OwnerEmployeeID" className="formControl" value={contact.OwnerEmployeeID || "63"} onChange={handleChange}>
<option value="63">Nirmalya Pal</option>
<option value="64">Officetrax Test Employee</option>
</select>
</div>
<div className="formGroup">
<label className="formLabel">Privacy Setting <span className="required">*</span></label>
<select name="PrivacySetting" className="formControl" value={contact.PrivacySetting || "PublicReadWrite"} onChange={handleChange}>
<option value="PublicReadWrite">Public read/write</option>
<option value="PublicReadOnly">Public read only</option>
<option value="Private">Private</option>
</select>
</div>
<div className="formGroup fullWidth">
<label className="formLabel">About</label>
<input name="About" className="formControl" placeholder="Enter details about the contact" value={contact.About || ""} onChange={handleChange} />
</div>
<div className="formGroup fullWidth">
<label className="formLabel">Tags</label>
<input name="Tags" className="formControl" placeholder="Enter tags (comma separated)" value={contact.Tags || ""} onChange={handleChange} />
<div className="formHint">Press Enter after each tag to complete it.</div>
</div>
</div>
</section>
<hr />
{/* Address */}
<section className="section">
<h5 className="sectionTitle">Address</h5>
<div className="contactFormBody">
<div className="formGroup">
<label className="formLabel">Address 1</label>
<input name="Address1" className="formControl" placeholder="Enter street address" value={contact.Address1 || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">Address 2</label>
<input name="Address2" className="formControl" placeholder="Enter apt/unit/suite number" value={contact.Address2 || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">City</label>
<input name="City" className="formControl" placeholder="Enter city name" value={contact.City || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">Zip/Postal Code</label>
<input name="Zip" className="formControl" placeholder="Enter code" value={contact.Zip || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">Country</label>
<select name="Country" className="formControl" value={contact.Country || "US"} onChange={handleChange}>
<option value="">Select a Country</option>
<option value="US">United States</option>
</select>
</div>
<div className="formGroup">
<label className="formLabel">State / Province</label>
<select name="State" className="formControl" value={contact.State || ""} onChange={handleChange}>
<option value="">Select a State/Province</option>
<option value="CA">California</option>
<option value="NY">New York</option>
</select>
</div>
</div>
</section>
<hr />
{/* Social Media */}
<section className="section">
<h5 className="sectionTitle">Social Media</h5>
<p className="muted">Enter the full URL of the Social Media profile you wish to link.</p>
<div className="contactFormBody">
<div className="formGroup">
<label className="formLabel">Facebook</label>
<input name="FacebookUrl" className="formControl" placeholder="Enter Facebook URL" value={contact.FacebookUrl || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">LinkedIn</label>
<input name="LinkedInUrl" className="formControl" placeholder="Enter LinkedIn URL" value={contact.LinkedInUrl || ""} onChange={handleChange} />
</div>
<div className="formGroup">
<label className="formLabel">Twitter</label>
<input name="TwitterUrl" className="formControl" placeholder="Enter Twitter URL" value={contact.TwitterUrl || ""} onChange={handleChange} />
</div>
</div>
</section>
<hr />
{/* Submit */}
<div className="formActions">
<div className="formNote">All fields marked with <span className="required">*</span> are required.</div>
<button className="btnPrimary" type="submit" disabled={loading}>
{loading ? "Saving..." : isEdit ? "Update Contact" : "Create Contact"}
</button>
</div>
</form>
</div>
</div>
);
};
export default ContactFormPage;

View file

@ -1,63 +1,111 @@
import React, { useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router";
import useItems from "./useItems";
import Table from "./Table";
import ActionBar from "../../../components/ActionBar";
import "./styles.css";
export default function ContactsList() {
const navigate = useNavigate();
const { rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err, reload } = useItems();
const [formState, setFormState] = useState({ open: false, item: null });
const [delState, setDelState] = useState({ open: false, item: null });
totalCount, loading, err } = useItems();
const [viewMode, setViewMode] = useState("list");
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const onSubmitSuccess = () => { setFormState({ open: false, item: null }); reload(); };
const onDeleteSuccess = () => { setDelState({ open: false, item: null }); reload(); };
return (
<section className="contactsPage">
<div className="pageTitle">Contacts</div>
<div className="card controlsCard">
<div className="controlsTop">
<div className="leftControls">
<div className="filter">
<select defaultValue="Name">
<option>Name</option>
<option>Account</option>
<option>Owner</option>
<option>Email</option>
<option>Phone</option>
<option>Title</option>
</select>
<input value={search} onChange={(e)=>setSearch(e.target.value)} placeholder="Enter search criteria here..." />
<button className="searchBtn">Search!</button>
<>
<ActionBar
moduleName="Contacts"
moduleIcon="fa-address-card"
onNew={() => navigate("/contacts/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">Contacts</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-address-card fa-stack-1x fa-inverse"></i>
</span>
<span>Search for Contacts</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="Name">
<option>Name</option>
<option>Account</option>
<option>Owner</option>
<option>Email</option>
<option>Phone</option>
<option>Title</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
<i className="fa fa-navicon"></i>
</button>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
<div className="rightControls">
<button className="iconSmall" title="New Contact" onClick={() => setFormState({ open: true, item: null })}>+</button>
</div>
</div>
<Table loading={loading} error={err} rows={rows} onEdit={(item)=> setFormState({ open: true, item })}
onDelete={(item)=> setDelState({ open: true, item })} page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} totalPages={totalPages} />
</div>
{/* {formState.open && (
<Form
open={formState.open}
item={formState.item}
onClose={() => setFormState({ open: false, item: null })}
onSuccess={onSubmitSuccess}
/>
)} */}
{/* {delState.open && (
<DeleteModal
open={delState.open}
item={delState.item}
onClose={() => setDelState({ open: false, item: null })}
onSuccess={onDeleteSuccess}
/>
)} */}
</section>
<Table loading={loading} error={err} rows={rows}
onEdit={(item) => navigate(`/contacts/edit/${item.id}`)}
onDelete={(item) => console.log("Delete", item)}
page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize}
totalCount={totalCount} totalPages={totalPages} />
</section>
</>
);
}

View file

@ -1,37 +1,31 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete,
page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "name", label: "Name", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "account", label: "Account" },
{ key: "title", label: "Title" },
{ key: "email", label: "Email" },
{ key: "phoneNumber", label: "Phone" },
{ key: "owner", label: "Owner" },
];
return (
<div className="tableWrap">
{error && <div style={{ color: "#b00020" }}>{error}</div>}
<table className="contactTable">
<thead>
<tr>
<th><input type="checkbox" /></th>
<th>Name</th><th>Account</th><th>Title</th><th>Email</th><th>Phone</th><th>Owner</th><th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={8}>Loading…</td></tr> :
rows.length === 0 ? <tr><td colSpan={8}>No records found.</td></tr> :
rows.map(r => (
<tr key={r.id}>
<td><input type="checkbox" /></td>
<td>{r.name}</td>
<td>{r.account}</td>
<td>{r.title}</td>
<td>{r.email}</td>
<td>{r.phoneNumber}</td>
<td>{r.owner}</td>
<td>
<button className="iconSmall" onClick={()=>onEdit(r)}>✎</button>
<button className="iconSmall" onClick={()=>onDelete(r)}>🗑</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<SharedTable
rows={rows}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
loading={loading}
error={error}
/>
);
}

View file

@ -1,6 +1,231 @@
/* CSS for modals, table, and controls – minimal to work with existing global styles */
.contactTable { width:100%; border-collapse:collapse; }
.contactTable th, .contactTable td { border:1px solid #ddd; padding:8px; }
/* Modal styles for Contacts module */
.modalRoot{position:fixed;inset:0;z-index:1000;}
.modalScrim{position:absolute;inset:0;background:rgba(0,0,0,.3);}
.modalCard{position:relative;margin:10% auto;padding:20px;background:#fff;width:400px;}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,70 @@
/* Dashboard Styles */
.dashboard-container {
padding: 20px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.kpi-card {
background: white;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.kpi-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-value {
font-size: 32px;
font-weight: bold;
color: #003a5d;
}
.analytics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.analysis-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.analysis-card h3 {
margin: 0 0 20px 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
@media (max-width: 768px) {
.analytics-grid {
grid-template-columns: 1fr;
}
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View file

@ -0,0 +1,130 @@
import React, { useEffect, useState } from "react";
import { getDashboardKPIs, getPriorityAnalysis, getInvoiceAnalysis } from "./api";
import "./Dashboard.css";
export default function Dashboard() {
const [kpis, setKpis] = useState(null);
const [priorityData, setPriorityData] = useState([]);
const [invoiceData, setInvoiceData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadDashboard = async () => {
try {
const [kpiRes, priorityRes, invoiceRes] = await Promise.all([
getDashboardKPIs(),
getPriorityAnalysis(),
getInvoiceAnalysis(),
]);
setKpis(kpiRes);
setPriorityData(priorityRes);
setInvoiceData(invoiceRes);
} catch (err) {
console.error("Failed to load dashboard", err);
} finally {
setLoading(false);
}
};
loadDashboard();
}, []);
if (loading) return <div className="loading">Loading dashboard...</div>;
return (
<section className="listPage">
<div className="pageTitle">Dashboard</div>
{/* KPI Cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", gap: "20px", marginBottom: "30px" }}>
<div className="card" style={{ padding: "20px", textAlign: "center" }}>
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px" }}>Total Accounts</div>
<div style={{ fontSize: "32px", fontWeight: "bold", color: "#003A5D" }}>{kpis?.totalAccounts}</div>
</div>
<div className="card" style={{ padding: "20px", textAlign: "center" }}>
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px" }}>Active Work Orders</div>
<div style={{ fontSize: "32px", fontWeight: "bold", color: "#007bff" }}>{kpis?.activeWorkOrders}</div>
</div>
<div className="card" style={{ padding: "20px", textAlign: "center" }}>
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px" }}>Completed Orders</div>
<div style={{ fontSize: "32px", fontWeight: "bold", color: "#28a745" }}>{kpis?.completedOrders}</div>
</div>
<div className="card" style={{ padding: "20px", textAlign: "center" }}>
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px" }}>Pending Follow-ups</div>
<div style={{ fontSize: "32px", fontWeight: "bold", color: "#ffc107" }}>{kpis?.pendingFollowUps}</div>
</div>
</div>
{/* Analytics Tables */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "20px" }}>
{/* Priority Analysis */}
<div className="card" style={{ padding: "20px" }}>
<h3 style={{ margin: "0 0 20px 0", fontSize: "16px", fontWeight: 600 }}>Priority Analysis</h3>
<table className="dataTable" style={{ width: "100%" }}>
<thead>
<tr>
<th>Priority</th>
<th>Count</th>
<th>%</th>
</tr>
</thead>
<tbody>
{priorityData.map((row, idx) => (
<tr key={idx}>
<td>
<span style={{
padding: "4px 12px",
borderRadius: "12px",
fontSize: "12px",
fontWeight: 500,
backgroundColor: row.priority === "Urgent" ? "#dc3545" : row.priority === "High" ? "#ff9800" : row.priority === "Medium" ? "#2196f3" : "#4caf50",
color: "#fff"
}}>
{row.priority}
</span>
</td>
<td style={{ fontWeight: 600 }}>{row.count}</td>
<td>{row.percentage}%</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Invoice Analysis */}
<div className="card" style={{ padding: "20px" }}>
<h3 style={{ margin: "0 0 20px 0", fontSize: "16px", fontWeight: 600 }}>Invoice Analysis</h3>
<table className="dataTable" style={{ width: "100%" }}>
<thead>
<tr>
<th>Status</th>
<th>Amount</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{invoiceData.map((row, idx) => (
<tr key={idx}>
<td>
<span style={{
padding: "4px 12px",
borderRadius: "12px",
fontSize: "12px",
fontWeight: 500,
backgroundColor: row.status === "Paid" ? "#4caf50" : row.status === "Pending" ? "#2196f3" : "#dc3545",
color: "#fff"
}}>
{row.status}
</span>
</td>
<td style={{ fontWeight: 600 }}>${row.amount.toLocaleString()}</td>
<td>{row.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,29 @@
// Mock dashboard data
export const getDashboardKPIs = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return {
totalAccounts: 24,
activeWorkOrders: 12,
completedOrders: 89,
pendingFollowUps: 7,
};
};
export const getPriorityAnalysis = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [
{ priority: "Urgent", count: 3, percentage: 15 },
{ priority: "High", count: 5, percentage: 25 },
{ priority: "Medium", count: 8, percentage: 40 },
{ priority: "Low", count: 4, percentage: 20 },
];
};
export const getInvoiceAnalysis = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [
{ status: "Paid", amount: 15000, count: 8 },
{ status: "Pending", amount: 8500, count: 5 },
{ status: "Overdue", amount: 3200, count: 2 },
];
};

View file

@ -0,0 +1,94 @@
// Mock employees data
const mockEmployees = [
{
id: 1,
name: "John Anderson",
email: "john.anderson@company.com",
phone: "(555) 123-4567",
jobTitle: "Senior Technician",
role: "Technician",
status: "Active",
hireDate: "2020-03-15",
address: "123 Main St, Springfield, IL 62701",
department: "Field Service",
},
{
id: 2,
name: "Sarah Martinez",
email: "sarah.martinez@company.com",
phone: "(555) 234-5678",
jobTitle: "Service Manager",
role: "Manager",
status: "Active",
hireDate: "2019-06-01",
address: "456 Oak Ave, Springfield, IL 62702",
department: "Operations",
},
{
id: 3,
name: "Mike Johnson",
email: "mike.johnson@company.com",
phone: "(555) 345-6789",
jobTitle: "Technician",
role: "Technician",
status: "Active",
hireDate: "2021-01-20",
address: "789 Pine Rd, Springfield, IL 62703",
department: "Field Service",
},
{
id: 4,
name: "Emily Chen",
email: "emily.chen@company.com",
phone: "(555) 456-7890",
jobTitle: "HR Specialist",
role: "Admin",
status: "Active",
hireDate: "2022-05-10",
address: "321 Oak St, Springfield, IL 62704",
department: "Admin",
},
{
id: 5,
name: "David Williams",
email: "david.williams@company.com",
phone: "(555) 567-8901",
jobTitle: "Field Supervisor",
role: "Supervisor",
status: "Active",
hireDate: "2018-11-30",
address: "654 Pine Ave, Springfield, IL 62705",
department: "Field Service",
},
];
export const getEmployees = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockEmployees];
};
export const getEmployeeById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockEmployees.find(e => e.id === parseInt(id));
};
export const addEmployee = async (employee) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newEmployee = { id: mockEmployees.length + 1, ...employee };
mockEmployees.push(newEmployee);
return newEmployee;
};
export const editEmployee = async (employee) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockEmployees.findIndex(e => e.id === employee.id);
if (idx >= 0) mockEmployees[idx] = employee;
return employee;
};
export const deleteEmployee = async (id) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockEmployees.findIndex(e => e.id === id);
if (idx >= 0) mockEmployees.splice(idx, 1);
return true;
};

View file

@ -0,0 +1,418 @@
/* Employee Form Page */
.employeeFormPage {
background: #f5f5f5;
min-height: 100vh;
padding: 0;
}
.panel {
margin-bottom: 20px;
background-color: #fff;
border: 1px solid transparent;
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.panel-default {
border-color: #ddd;
}
.panel-connected {
border-radius: 0;
border: none;
box-shadow: none;
}
.panel-body {
padding: 15px;
}
/* Breadcrumb */
.breadcrumb {
padding: 8px 15px;
margin-bottom: 20px;
list-style: none;
background-color: #f5f5f5;
border-radius: 4px;
}
.breadcrumb-alt {
background-color: white;
border-bottom: 1px solid #e5e5e5;
padding: 10px 20px;
margin: 0 0 20px 0;
border-radius: 0;
}
.breadcrumb > li {
display: inline-block;
}
.breadcrumb > li + li:before {
content: ">\00a0";
padding: 0 5px;
color: #ccc;
}
.breadcrumb > li a {
color: #0088cc;
text-decoration: none;
}
.breadcrumb > li a:hover {
text-decoration: underline;
}
.breadcrumb > .active {
color: #00a0d1;
font-weight: bold;
cursor: default;
}
/* Container */
.container {
padding-right: 15px;
padding-left: 15px;
margin-right: auto;
margin-left: auto;
}
/* Form Header */
.header {
margin-bottom: 15px;
}
.heading {
margin: 0 0 10px 0;
font-weight: 300;
line-height: 1.1;
color: #333;
}
.heading.thin {
font-weight: 300;
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.heading.text-uppercase {
text-transform: uppercase;
font-size: 14px;
margin-bottom: 15px;
}
.text-primary {
color: #0088cc;
}
.fa-stack {
position: relative;
display: inline-block;
width: 2em;
height: 2em;
line-height: 2em;
vertical-align: middle;
}
.fa-stack-1x,
.fa-stack-2x {
position: absolute;
left: 0;
width: 100%;
text-align: center;
}
.fa-stack-2x {
font-size: 2em;
}
.fa-stack-1x {
font-size: 1em;
line-height: inherit;
}
.fa-inverse {
color: #fff;
}
/* Form Groups */
.form-group {
margin-bottom: 15px;
position: relative;
}
/* Row and Columns */
.row {
margin-right: -15px;
margin-left: -15px;
display: flex;
flex-wrap: wrap;
}
.row::after {
content: "";
display: table;
clear: both;
}
[class*="col-"] {
position: relative;
min-height: 1px;
padding-right: 15px;
padding-left: 15px;
}
.col-sm-2 {
width: 16.66666667%;
}
.col-sm-4 {
width: 33.33333333%;
}
.col-sm-5 {
width: 41.66666667%;
}
.col-sm-6 {
width: 50%;
}
@media (min-width: 992px) {
.col-md-4 {
width: 33.33333333%;
}
}
/* Labels */
label {
display: inline-block;
max-width: 100%;
margin-bottom: 5px;
font-weight: 600;
font-size: 13px;
color: #333;
}
.required-field::after {
content: " *";
color: #d9534f;
}
/* Input Counter */
.input-counter {
position: absolute;
top: 0;
right: 15px;
font-size: 11px;
color: #999;
z-index: 10;
}
/* Form Controls */
.form-control {
display: block;
width: 100%;
height: 34px;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
box-sizing: border-box;
}
.form-control:focus {
border-color: #66afe9;
outline: 0;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
}
select.form-control {
height: 34px;
padding: 6px 12px;
}
/* Form Actions */
.form-actions {
padding: 19px 20px 20px;
margin-top: 20px;
margin-bottom: 20px;
border-top: 1px solid #e5e5e5;
text-align: right;
}
/* Buttons */
.btn {
display: inline-block;
padding: 6px 12px;
margin-bottom: 0;
font-size: 14px;
font-weight: 400;
line-height: 1.42857143;
text-align: center;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: 4px;
user-select: none;
}
.btn:focus,
.btn:active:focus {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px;
}
.btn:hover,
.btn:focus {
color: #333;
text-decoration: none;
}
.btn-default {
color: #333;
background-color: #fff;
border-color: #ccc;
}
.btn-default:hover,
.btn-default:focus {
color: #333;
background-color: #e6e6e6;
border-color: #adadad;
}
.btn-primary {
color: #fff;
background-color: #0088cc;
border-color: #0088cc;
}
.btn-primary:hover,
.btn-primary:focus {
color: #fff;
background-color: #006699;
border-color: #005580;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.65;
box-shadow: none;
}
/* Sub-heading */
.sub-heading {
color: #666;
font-size: 13px;
margin-top: 5px;
margin-bottom: 10px;
}
/* Input Group */
.input-group {
position: relative;
display: table;
border-collapse: separate;
width: 100%;
}
.input-group .form-control {
position: relative;
z-index: 2;
float: left;
width: 100%;
margin-bottom: 0;
display: table-cell;
}
.input-group-btn {
position: relative;
font-size: 0;
white-space: nowrap;
width: 1%;
vertical-align: middle;
display: table-cell;
}
.input-group-btn > .btn {
position: relative;
border-radius: 0 4px 4px 0;
}
.input-group .form-control:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
/* Form Control Label */
.form-control-label {
display: block;
margin-top: 5px;
font-size: 12px;
color: #666;
}
/* Text Utilities */
.text-muted {
color: #777;
}
.text-sm {
font-size: 13px;
}
.text-right {
text-align: right;
}
.text-pad-right {
padding-right: 10px;
}
/* Button Success */
.btn-success {
color: #fff;
background-color: #5cb85c;
border-color: #4cae4c;
}
.btn-success:hover,
.btn-success:focus {
color: #fff;
background-color: #449d44;
border-color: #398439;
}
.btn-lg {
padding: 10px 16px;
font-size: 18px;
line-height: 1.33;
border-radius: 6px;
}
/* HR */
hr {
margin-top: 20px;
margin-bottom: 20px;
border: 0;
border-top: 1px solid #e5e5e5;
}
/* Responsive */
@media (max-width: 767px) {
[class*="col-sm-"] {
width: 100%;
}
}

View file

@ -1,188 +1,675 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { addEmployee } from "../new/api";
import { editEmployee } from "../edit/api";
import "./ContactFormPage.css";
import { getEmployeeById, addEmployee, editEmployee } from "../api";
import "./EmployeeFormPage.css";
export default function EmployeeFormPage() {
const { id } = useParams();
const isEdit = !!id;
const navigate = useNavigate();
const isEdit = !!id;
const [formData, setFormData] = useState({
firstName: "",
middleName: "",
lastName: "",
email: "",
phone: "",
jobTitle: "",
role: "Technician",
status: "Active",
hireDate: "",
dateOfBirth: "",
address: "",
department: "Field Service",
timeZone: "Eastern Standard Time",
gender: "",
employeeNumber: "",
regionName: "",
userName: "",
confirmUserName: "",
street: "",
streetLineTwo: "",
city: "",
zip: "",
country: "US",
state: "",
county: "",
smsAddress: "",
website: "",
facebookUrl: "",
linkedInUrl: "",
twitterUrl: "",
});
const [loading, setLoading] = useState(false);
const [employee, setEmployee] = useState({});
useEffect(() => {
if (isEdit && id) {
const loadEmployee = async () => {
try {
const employee = await getEmployeeById(id);
if (employee) {
setFormData(employee);
}
} catch (err) {
console.error("Failed to load employee", err);
toast.error("Failed to load employee");
}
};
loadEmployee();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setEmployee((p) => ({
...p,
[name]: type === "checkbox" ? checked : value,
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e) => {
const handleSave = async (e) => {
e.preventDefault();
if (!employee.FirstName || !employee.LastName) {
toast.error("First & last name required");
return;
}
setLoading(true);
try {
setLoading(true);
if (isEdit) {
await editEmployee(employee);
toast.success("Employee updated!");
await editEmployee(formData);
toast.success("Employee updated successfully!");
} else {
await addEmployee(employee);
toast.success("Employee created!");
await addEmployee(formData);
toast.success("Employee created successfully!");
}
navigate("/employees");
} catch (err) {
console.error(err);
toast.error("Operation failed.");
toast.error("Failed to save employee");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/employees");
};
return (
<div className="contactFormWrapper">
<div className="formCard">
<div className="formHeader">
<h2>{isEdit ? "Edit Employee" : "Create Employee"}</h2>
</div>
<div className="employeeFormPage">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); navigate("/employees"); }}>
Employees
</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<form onSubmit={handleSubmit} className="contactForm">
{/* General Section */}
<section className="section">
<h5 className="sectionTitle">General Details</h5>
<div className="contactFormBody">
<div className="formGroup">
<label className="formLabel">
First Name <span className="required">*</span>
</label>
<input
name="FirstName"
className="formControl"
value={employee.FirstName ?? ""}
onChange={handleChange}
/>
<div className="container">
<form onSubmit={handleSave}>
{/* Page Header */}
<div className="form-group">
<div className="header">
<h3 className="heading thin">
<span className="fa-stack" style={{ fontSize: "0.9em" }}>
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className={`fa fa-${isEdit ? "edit" : "plus"} fa-stack-1x fa-inverse`}></i>
</span>
<span>{isEdit ? "Edit an Employee" : "Create an Employee"}</span>
</h3>
</div>
</div>
<div className="formGroup">
<label className="formLabel">Middle Name</label>
<input
name="MiddleName"
className="formControl"
value={employee.MiddleName ?? ""}
onChange={handleChange}
/>
{/* General Details Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>General Details</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-5">
<div className="form-group">
<span className="input-counter">46</span>
<label className="required-field" htmlFor="firstName">First Name</label>
<input
className="form-control"
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter first name"
required
/>
</div>
</div>
<div className="col-sm-2">
<div className="form-group">
<span className="input-counter">50</span>
<label htmlFor="middleName">Middle Name</label>
<input
className="form-control"
type="text"
id="middleName"
name="middleName"
value={formData.middleName}
onChange={handleChange}
placeholder="Enter middle name"
/>
</div>
</div>
<div className="col-sm-5">
<div className="form-group">
<span className="input-counter">44</span>
<label className="required-field" htmlFor="lastName">Last Name</label>
<input
className="form-control"
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter last name"
required
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="department">Department</label>
<select
className="form-control"
id="department"
name="department"
value={formData.department}
onChange={handleChange}
>
<option value="">Select a Department</option>
<option value="Administration">Administration</option>
<option value="Sales">Sales</option>
<option value="Service">Service</option>
</select>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label className="required-field" htmlFor="timeZone">Time Zone</label>
<select
className="form-control"
id="timeZone"
name="timeZone"
value={formData.timeZone}
onChange={handleChange}
required
>
<option value="">Select a time zone</option>
<option value="Eastern Standard Time">(UTC-05:00) Eastern Time (US & Canada)</option>
<option value="Central Standard Time">(UTC-06:00) Central Time (US & Canada)</option>
<option value="Mountain Standard Time">(UTC-07:00) Mountain Time (US & Canada)</option>
<option value="Pacific Standard Time">(UTC-08:00) Pacific Time (US & Canada)</option>
</select>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="gender">Gender</label>
<select
className="form-control"
id="gender"
name="gender"
value={formData.gender}
onChange={handleChange}
>
<option value="">Select a gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Not specified">Not specified</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="dateOfBirth">Date of Birth</label>
<input
className="form-control"
type="date"
id="dateOfBirth"
name="dateOfBirth"
value={formData.dateOfBirth}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="jobTitle">Job Title</label>
<select
className="form-control"
id="jobTitle"
name="jobTitle"
value={formData.jobTitle}
onChange={handleChange}
>
<option value="">Select a Job Title</option>
<option value="Technician">Technician</option>
<option value="Service Associate">Service Associate</option>
<option value="Accountant">Accountant</option>
<option value="Salesperson">Salesperson</option>
<option value="President">President</option>
</select>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<span className="input-counter">50</span>
<label htmlFor="employeeNumber">Employee #</label>
<input
className="form-control"
type="text"
id="employeeNumber"
name="employeeNumber"
value={formData.employeeNumber}
onChange={handleChange}
placeholder="Enter Employee number"
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="hireDate">Hire Date</label>
<input
className="form-control"
type="date"
id="hireDate"
name="hireDate"
value={formData.hireDate}
onChange={handleChange}
/>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="regionName">Region Name</label>
<select
className="form-control"
id="regionName"
name="regionName"
value={formData.regionName}
onChange={handleChange}
>
<option value="">Select a Region Name</option>
<option value="Region 1">Region 1</option>
<option value="Region 2">Region 2</option>
</select>
</div>
</div>
</div>
</div>
<div className="formGroup">
<label className="formLabel">
Last Name <span className="required">*</span>
</label>
<input
name="LastName"
className="formControl"
value={employee.LastName ?? ""}
onChange={handleChange}
/>
<hr />
{/* Login & Role Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Login &amp; Role</strong>
</h5>
<p className="sub-heading">
{isEdit
? "If the Username is changed, the Employee's account will have to be confirmed again before they can log in. A confirmation email will automatically be sent."
: "A confirmation email will be sent to activate the Employee's account, as well as a temporary password. The Employee's account must be activated before they will be able to log in."}
</p>
</div>
<div className="row">
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="role">Role</label>
<select
className="form-control"
id="role"
name="role"
value={formData.role}
onChange={handleChange}
required
>
<option value="">Select a role</option>
<option value="Admin">Admin</option>
<option value="Administrator User">Administrator User</option>
<option value="Dispatcher">Dispatcher</option>
<option value="Editor User">Editor User</option>
<option value="Employee">Employee</option>
<option value="Read Only User">Read Only User</option>
</select>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="userName">User Name</label>
<input
className="form-control"
type="email"
id="userName"
name="userName"
value={formData.userName || formData.email}
onChange={handleChange}
placeholder="address@email.com"
required
/>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="confirmUserName">Confirm User Name</label>
<div className="input-group">
<input
className="form-control"
type="email"
id="confirmUserName"
name="confirmUserName"
value={formData.confirmUserName || formData.email}
onChange={handleChange}
placeholder="address@email.com"
required
/>
<span className="input-group-btn">
<button type="button" title="Send Test Email" className="btn btn-primary">
<i className="fa fa-envelope"></i>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
<div className="formGroup">
<label className="formLabel">Job Title</label>
<input
name="JobTitle"
className="formControl"
value={employee.JobTitle ?? ""}
onChange={handleChange}
/>
<hr />
{/* Address Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Address</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<span className="input-counter">250</span>
<label htmlFor="street">Address 1</label>
<input
className="form-control"
type="text"
id="street"
name="street"
value={formData.street || ""}
onChange={handleChange}
placeholder="Enter street address or P.O. box"
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<span className="input-counter">250</span>
<label htmlFor="streetLineTwo">Address 2</label>
<input
className="form-control"
type="text"
id="streetLineTwo"
name="streetLineTwo"
value={formData.streetLineTwo || ""}
onChange={handleChange}
placeholder="Enter apt/unit/suite number (ex. Unit 1)"
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<span className="input-counter">100</span>
<label htmlFor="city">City</label>
<input
className="form-control"
type="text"
id="city"
name="city"
value={formData.city || ""}
onChange={handleChange}
placeholder="Enter city name"
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<span className="input-counter">20</span>
<label htmlFor="zip">Zip/Postal Code</label>
<input
className="form-control"
type="text"
id="zip"
name="zip"
value={formData.zip || ""}
onChange={handleChange}
placeholder="Enter code"
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="country">Country</label>
<select
className="form-control"
id="country"
name="country"
value={formData.country || "US"}
onChange={handleChange}
>
<option value="">Select a Country</option>
<option value="US">United States</option>
</select>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="state">State / Province</label>
<select
className="form-control"
id="state"
name="state"
value={formData.state || ""}
onChange={handleChange}
>
<option value="">Select a State/Province</option>
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="CA">California</option>
<option value="FL">Florida</option>
<option value="NY">New York</option>
<option value="TX">Texas</option>
</select>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="county">County</label>
<select
className="form-control"
id="county"
name="county"
value={formData.county || ""}
onChange={handleChange}
>
<option value="">Select a County</option>
</select>
</div>
</div>
</div>
</div>
<div className="formGroup">
<label className="formLabel">Department</label>
<input
name="Department"
className="formControl"
value={employee.Department ?? ""}
onChange={handleChange}
/>
<hr />
{/* Phone & Email Communication Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Phone &amp; Email Communication</strong>
</h5>
</div>
<div className="form-group">
<label>Phone Number</label>
<p className="text-muted text-sm">Add phone numbers for this employee</p>
</div>
<div className="form-group">
<label>Email Addresses</label>
<p className="text-muted text-sm">Add email addresses for this employee</p>
</div>
</div>
<div className="formGroup">
<label className="formLabel">Status</label>
<select
name="Status"
className="formControl"
value={employee.Status ?? ""}
onChange={handleChange}
<hr />
{/* Additional Details Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Additional Details</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="smsAddress">SMS Address</label>
<div className="input-group">
<input
className="form-control"
type="email"
id="smsAddress"
name="smsAddress"
value={formData.smsAddress || ""}
onChange={handleChange}
placeholder="13625550100@providerdomain.com"
/>
<span className="input-group-btn">
<button type="button" title="Test SMS Text Message" className="btn btn-primary">
<i className="fa fa-comment"></i>
</button>
</span>
</div>
<span className="form-control-label">
Do not include brackets or dashes for the number. Contact your cell provider or visit their support website to find your email to SMS address.
</span>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<span className="input-counter">2048</span>
<label htmlFor="website">Website</label>
<input
className="form-control"
type="url"
id="website"
name="website"
value={formData.website || ""}
onChange={handleChange}
placeholder="http://"
/>
<span className="form-control-label">Make sure to include the protocol (http://)</span>
</div>
</div>
</div>
</div>
<hr />
{/* Social Media Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Social Media</strong>
</h5>
</div>
<p className="text-muted text-sm">
Enter the full URL of the Social Media profile you wish to link.
</p>
<div className="row">
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="facebookUrl">Facebook</label>
<input
className="form-control"
type="text"
id="facebookUrl"
name="facebookUrl"
value={formData.facebookUrl || ""}
onChange={handleChange}
placeholder="Enter Facebook URL"
/>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="linkedInUrl">LinkedIn</label>
<input
className="form-control"
type="text"
id="linkedInUrl"
name="linkedInUrl"
value={formData.linkedInUrl || ""}
onChange={handleChange}
placeholder="Enter LinkedIn URL"
/>
</div>
</div>
<div className="col-sm-6 col-md-4">
<div className="form-group">
<label htmlFor="twitterUrl">Twitter</label>
<input
className="form-control"
type="text"
id="twitterUrl"
name="twitterUrl"
value={formData.twitterUrl || ""}
onChange={handleChange}
placeholder="Enter Twitter URL"
/>
</div>
</div>
</div>
</div>
<hr />
{/* Action Buttons */}
<div className="form-group text-right">
<span className="text-pad-right text-sm">
All fields marked with a <span className="required-field"></span> are required.
</span>
<button
type="submit"
className="btn btn-lg btn-success"
disabled={loading}
>
<option value="">Select</option>
<option value="Active">Active</option>
<option value="Terminated">Terminated</option>
<option value="Leave">Leave</option>
</select>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
<div className="formGroup fullWidth">
<label className="formLabel">About</label>
<input
name="About"
className="formControl"
value={employee.About ?? ""}
onChange={handleChange}
/>
</div>
</div>
</section>
<hr />
{/* Contact info */}
<section className="section">
<h5 className="sectionTitle">Contact Information</h5>
<div className="contactFormBody">
<div className="formGroup">
<label className="formLabel">Email</label>
<input
name="Email"
className="formControl"
value={employee.Email ?? ""}
onChange={handleChange}
/>
</div>
<div className="formGroup">
<label className="formLabel">Phone</label>
<input
name="Phone"
className="formControl"
value={employee.Phone ?? ""}
onChange={handleChange}
/>
</div>
</div>
</section>
<hr />
{/* Submit */}
<div className="formActions">
<div className="formNote">
All fields marked <span className="required">*</span> are required
</div>
<button className="btnPrimary" type="submit" disabled={loading}>
{loading ? "Saving..." : isEdit ? "Update Employee" : "Create Employee"}
</button>
</form>
</div>
</form>
</div>
</div>
</div>
);

View file

@ -1,104 +1,137 @@
import React, { useEffect, useState } from "react";
import { fetchEmployeesPaged, deleteEmployee } from "./api";
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import useEmployees from "./useEmployees";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import ActionBar from "../../../components/ActionBar";
import { deleteEmployee } from "../api";
import "./styles.css";
export default function EmployeeList() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload } = useEmployees();
const [deleteModal, setDeleteModal] = useState({ isOpen: false, item: null, loading: false });
const [viewMode, setViewMode] = useState("list"); // "list" or "card"
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const load = async () => {
setLoading(true);
const res = await fetchEmployeesPaged({ search });
setRows(res?.rows ?? []);
setLoading(false);
const handleDeleteClick = (item) => {
setDeleteModal({ isOpen: true, item, loading: false });
};
useEffect(() => {
load();
}, [search]);
const handleDelete = async (id) => {
if (!window.confirm("Delete this employee?")) return;
await deleteEmployee(id);
load();
const handleDeleteConfirm = async () => {
setDeleteModal(prev => ({ ...prev, loading: true }));
try {
await deleteEmployee(deleteModal.item.id);
toast.success(`Employee deleted successfully!`);
setDeleteModal({ isOpen: false, item: null, loading: false });
reload();
} catch (err) {
console.error(err);
toast.error("Failed to delete employee");
setDeleteModal(prev => ({ ...prev, loading: false }));
}
};
return (
<section className="contactsPage">
<div className="pageTitle">Employees</div>
<>
<ActionBar
moduleName="Employees"
moduleIcon="fa-user"
onNew={() => navigate("/employees/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<div className="card controlsCard">
<div className="controlsTop">
<div className="leftControls">
<div className="filter">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchBtn">Search!</button>
</div>
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">Employees</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-user fa-stack-1x fa-inverse"></i>
</span>
<span>Search for Employees</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="rightControls">
<button
className="iconSmall"
title="New Employee"
onClick={() => navigate("/employees/new")}
>
+
</button>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="Name">
<option>Name</option>
<option>Email</option>
<option>Phone</option>
<option>Role</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
<i className="fa fa-navicon"></i>
</button>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
</div>
{loading ? (
<div style={{ padding: 12 }}>Loading employees...</div>
) : (
<table className="contactTable">
<thead>
<tr>
<th>Name</th>
<th>Job Title</th>
<th>Email</th>
<th>Phone</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<Table loading={loading} error={err} rows={rows}
onEdit={(item) => navigate(`/employees/edit/${item.id}`)}
onDelete={handleDeleteClick}
page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize}
totalCount={totalCount} totalPages={totalPages} />
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.name}</td>
<td>{r.jobTitle}</td>
<td>{r.email}</td>
<td>{r.phone}</td>
<td>{r.status}</td>
<td className="iconActions">
<button
className="iconBtn"
title="Edit"
onClick={() => navigate(`/employees/${r.id}`)}
>
✏️
</button>
<button
className="iconBtnDanger"
title="Delete"
onClick={() => handleDelete(r.id)}
>
🗑
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete Employee"
message={`Are you sure you want to delete "${deleteModal.item?.name}"? This action cannot be undone.`}
loading={deleteModal.loading}
/>
</section>
</>
);
}

View file

@ -1,37 +1,32 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete,
page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "name", label: "Name", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "jobTitle", label: "Job Title" },
{ key: "role", label: "Role" },
{ key: "email", label: "Email" },
{ key: "phone", label: "Phone" },
{ key: "status", label: "Status" },
{ key: "lastLogin", label: "Last Login" },
];
return (
<div className="tableWrap">
{error && <div style={{ color: "#b00020" }}>{error}</div>}
<table className="contactTable">
<thead>
<tr>
<th><input type="checkbox" /></th>
<th>Name</th><th>Account</th><th>Title</th><th>Email</th><th>Phone</th><th>Owner</th><th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={8}>Loading…</td></tr> :
rows.length === 0 ? <tr><td colSpan={8}>No records found.</td></tr> :
rows.map(r => (
<tr key={r.id}>
<td><input type="checkbox" /></td>
<td>{r.name}</td>
<td>{r.account}</td>
<td>{r.title}</td>
<td>{r.email}</td>
<td>{r.phoneNumber}</td>
<td>{r.owner}</td>
<td>
<button className="iconSmall" onClick={()=>onEdit(r)}>✎</button>
<button className="iconSmall" onClick={()=>onDelete(r)}>🗑</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<SharedTable
rows={rows}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
loading={loading}
error={error}
/>
);
}

View file

@ -1,6 +1,231 @@
/* CSS for modals, table, and controls – minimal to work with existing global styles */
.contactTable { width:100%; border-collapse:collapse; }
.contactTable th, .contactTable td { border:1px solid #ddd; padding:8px; }
/* Modal styles for Employees module */
.modalRoot{position:fixed;inset:0;z-index:1000;}
.modalScrim{position:absolute;inset:0;background:rgba(0,0,0,.3);}
.modalCard{position:relative;margin:10% auto;padding:20px;background:#fff;width:400px;}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { getEmployees } from "../api";
const useEmployees = () => {
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const reload = async () => {
setLoading(true);
try {
const employees = await getEmployees();
let filtered = employees;
if (search) {
filtered = employees.filter((e) =>
e.name.toLowerCase().includes(search.toLowerCase()) ||
e.email.toLowerCase().includes(search.toLowerCase()) ||
e.phone.includes(search)
);
}
setTotalCount(filtered.length);
const start = (page - 1) * pageSize;
setRows(filtered.slice(start, start + pageSize));
} catch (error) {
setErr(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
export default useEmployees;

64
src/pages/followup/api.js Normal file
View file

@ -0,0 +1,64 @@
// Mock follow-ups data
const mockFollowUps = [
{
id: 1,
workOrder: "WO-001",
reason: "Quarterly maintenance check",
scheduledDate: "2024-01-22",
status: "Pending",
createdBy: "John Anderson",
createdDate: "2024-01-15",
notes: "Check all HVAC filters and oil levels",
},
{
id: 2,
workOrder: "WO-002",
reason: "Verify electrical repairs",
scheduledDate: "2024-01-25",
status: "Pending",
createdBy: "Sarah Martinez",
createdDate: "2024-01-16",
notes: "Test all outlets to ensure proper operation",
},
{
id: 3,
workOrder: "WO-003",
reason: "Post-repair inspection",
scheduledDate: "2024-01-20",
status: "Completed",
createdBy: "Mike Johnson",
createdDate: "2024-01-10",
notes: "Plumbing inspection completed successfully",
},
];
export const getFollowUps = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockFollowUps];
};
export const getFollowUpById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockFollowUps.find(f => f.id === parseInt(id));
};
export const addFollowUp = async (followUp) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newFollowUp = { id: mockFollowUps.length + 1, ...followUp };
mockFollowUps.push(newFollowUp);
return newFollowUp;
};
export const editFollowUp = async (followUp) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockFollowUps.findIndex(f => f.id === followUp.id);
if (idx >= 0) mockFollowUps[idx] = followUp;
return followUp;
};
export const deleteFollowUp = async (id) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockFollowUps.findIndex(f => f.id === id);
if (idx >= 0) mockFollowUps.splice(idx, 1);
return true;
};

View file

@ -1,106 +1,219 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getFollowUpById, saveFollowUp } from "../new/api";
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getFollowUpById, addFollowUp, editFollowUp } from "../api";
export default function FollowUpFormPage() {
const navigate = useNavigate();
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [form, setForm] = useState({
id: 0,
followUpTypeId: 0,
workOrderId: null,
scheduleDate: "",
scheduleTime: "",
employeeId: "",
const [formData, setFormData] = useState({
workOrder: "",
reason: "",
completeFollowUp: false,
scheduledDate: "",
status: "Pending",
createdBy: "",
createdDate: new Date().toISOString().split("T")[0],
notes: "",
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (id) {
getFollowUpById(id).then((res) => {
setForm(res);
});
if (isEdit && id) {
const loadFollowUp = async () => {
try {
const followUp = await getFollowUpById(id);
if (followUp) {
setFormData(followUp);
}
} catch (err) {
console.error("Failed to load follow up", err);
toast.error("Failed to load follow up");
}
};
loadFollowUp();
}
}, [id]);
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm({
...form,
[name]: type === "checkbox" ? checked : value,
});
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e) => {
const handleSave = async (e) => {
e.preventDefault();
await saveFollowUp(form);
setLoading(true);
try {
if (isEdit) {
await editFollowUp(formData);
toast.success("Follow-up updated successfully!");
} else {
await addFollowUp(formData);
toast.success("Follow-up created successfully!");
}
navigate("/followups");
} catch (err) {
console.error(err);
toast.error("Failed to save follow-up");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/followups");
};
return (
<div className="formPage">
<h2>{id ? "Edit Follow Up" : "Add Follow Up"}</h2>
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit Follow-up" : "Create Follow-up"}
</div>
<form onSubmit={handleSubmit}>
<label>Schedule Date</label>
<input
type="date"
name="scheduleDate"
value={form.scheduleDate}
onChange={handleChange}
required
/>
<form onSubmit={handleSave} className="formWrapper">
<div className="card formCard">
<div className="formBody">
{/* BASIC INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Follow-up Details</div>
<div className="formRow">
<div className="formGroup">
<label>Work Order *</label>
<select
name="workOrder"
value={formData.workOrder}
onChange={handleChange}
required
>
<option value="">Select Work Order</option>
<option value="WO-001">WO-001 - HVAC System Maintenance</option>
<option value="WO-002">WO-002 - Electrical Repair</option>
<option value="WO-003">WO-003 - Plumbing Issue</option>
</select>
</div>
<div className="formGroup">
<label>Reason *</label>
<input
type="text"
name="reason"
value={formData.reason}
onChange={handleChange}
required
placeholder="Follow-up reason"
/>
</div>
</div>
<label>Schedule Time</label>
<input
type="time"
name="scheduleTime"
value={form.scheduleTime}
onChange={handleChange}
required
/>
<div className="formRow">
<div className="formGroup">
<label>Scheduled Date *</label>
<input
type="date"
name="scheduledDate"
value={formData.scheduledDate}
onChange={handleChange}
required
/>
</div>
<div className="formGroup">
<label>Status *</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
required
>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Completed">Completed</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
<label>Assigned To (Employee Id)</label>
<input
name="employeeId"
value={form.employeeId}
onChange={handleChange}
required
/>
{/* CREATOR INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Creator Information</div>
<div className="formRow">
<div className="formGroup">
<label>Created By *</label>
<select
name="createdBy"
value={formData.createdBy}
onChange={handleChange}
required
>
<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>
</select>
</div>
<div className="formGroup">
<label>Created Date *</label>
<input
type="date"
name="createdDate"
value={formData.createdDate}
onChange={handleChange}
required
/>
</div>
</div>
</div>
<label>Reason</label>
<textarea
name="reason"
value={form.reason}
onChange={handleChange}
required
/>
{/* NOTES SECTION */}
<div className="formSection">
<div className="sectionTitle">Notes</div>
<div className="formRow">
<div className="formGroup fullWidth">
<label>Notes</label>
<textarea
name="notes"
value={formData.notes}
onChange={handleChange}
placeholder="Additional notes or details"
rows="5"
style={{
width: "100%",
padding: "10px",
borderRadius: "4px",
border: "1px solid #ddd",
fontFamily: "inherit",
}}
/>
</div>
</div>
</div>
</div>
<label>
<input
type="checkbox"
name="completeFollowUp"
checked={form.completeFollowUp}
onChange={handleChange}
/>
Complete Follow Up
</label>
<div className="formActions">
<button type="submit" className="btn btn-success">
Save
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate("/followups")}
>
Cancel
</button>
{/* ACTION BUTTONS */}
<div className="formActions">
<button
type="button"
className="btnSecondary"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btnPrimary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</div>
</form>
</div>
</section>
);
}

View file

@ -1,98 +1,144 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchFollowUpsPaged, deleteFollowUp } from "../new/api";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import useFollowUps from "./useFollowUps";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import ActionBar from "../../../components/ActionBar";
import { deleteFollowUp } from "../api";
import "./styles.css";
export default function FollowUpList() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload } = useFollowUps();
const [viewMode, setViewMode] = useState("list");
const [deleteModal, setDeleteModal] = useState({ isOpen: false, item: null, loading: false });
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const loadData = async () => {
const res = await fetchFollowUpsPaged({ search, page });
setRows(res.rows);
setTotalPages(res.totalPages);
const handleDeleteClick = (item) => {
setDeleteModal({ isOpen: true, item, loading: false });
};
useEffect(() => {
loadData();
}, [page]);
const handleDeleteConfirm = async () => {
setDeleteModal(prev => ({ ...prev, loading: true }));
try {
await deleteFollowUp(deleteModal.item.id);
toast.success(`Follow-up deleted successfully!`);
setDeleteModal({ isOpen: false, item: null, loading: false });
reload();
} catch (err) {
console.error(err);
toast.error("Failed to delete follow-up");
setDeleteModal(prev => ({ ...prev, loading: false }));
}
};
return (
<div>
<div className="pageHeader">
<h2>Follow Ups</h2>
<button
className="btn btn-primary"
onClick={() => navigate("/followups/new")}
>
+ Add Follow Up
</button>
</div>
<>
<ActionBar
moduleName="Follow Ups"
moduleIcon="fa-hourglass"
onNew={() => navigate("/followups/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<div className="searchRow">
<input
placeholder="Search follow ups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">Follow Ups</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-hourglass fa-stack-1x fa-inverse"></i>
</span>
<span>Search for Follow Ups</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="WorkOrder">
<option>Work Order</option>
<option>Reason</option>
<option>Status</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
<i className="fa fa-navicon"></i>
</button>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
</div>
<Table
loading={loading}
error={err}
rows={rows}
onEdit={(item) => navigate(`/followups/edit/${item.id}`)}
onDelete={handleDeleteClick}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
totalPages={totalPages}
/>
<button onClick={loadData}>Search</button>
</div>
<table className="table">
<thead>
<tr>
<th>Reason</th>
<th>Schedule Date</th>
<th>Account</th>
<th>Assigned To</th>
<th>Work Order</th>
<th>Status</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((x) => (
<tr key={x.id}>
<td>{x.reason}</td>
<td>{x.scheduleDate}</td>
<td>{x.account}</td>
<td>{x.assignedTo}</td>
<td>{x.workOrderNumber}</td>
<td>{x.status}</td>
<td>
<button onClick={() => navigate(`/followups/edit/${x.id}`)}>
Edit
</button>
<button onClick={() => deleteFollowUp(x.id)}>
Delete
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan="7">No follow ups found</td>
</tr>
)}
</tbody>
</table>
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
Prev
</button>
<span>
Page {page} / {totalPages}
</span>
<button
disabled={page === totalPages}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete Follow-up"
message={`Are you sure you want to delete this follow-up for "${deleteModal.item?.workOrder}"? This action cannot be undone.`}
loading={deleteModal.loading}
/>
</section>
</>
);
}

View file

@ -1,37 +1,16 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete, page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "workOrder", label: "Work Order", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "reason", label: "Reason" },
{ key: "scheduledDate", label: "Scheduled Date" },
{ key: "status", label: "Status" },
{ key: "createdBy", label: "Created By" },
];
export default function Table({ loading, error, rows, onEdit, onDelete,
page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
return (
<div className="tableWrap">
{error && <div style={{ color: "#b00020" }}>{error}</div>}
<table className="contactTable">
<thead>
<tr>
<th><input type="checkbox" /></th>
<th>Name</th><th>Account</th><th>Title</th><th>Email</th><th>Phone</th><th>Owner</th><th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? <tr><td colSpan={8}>Loading…</td></tr> :
rows.length === 0 ? <tr><td colSpan={8}>No records found.</td></tr> :
rows.map(r => (
<tr key={r.id}>
<td><input type="checkbox" /></td>
<td>{r.name}</td>
<td>{r.account}</td>
<td>{r.title}</td>
<td>{r.email}</td>
<td>{r.phoneNumber}</td>
<td>{r.owner}</td>
<td>
<button className="iconSmall" onClick={()=>onEdit(r)}>✎</button>
<button className="iconSmall" onClick={()=>onDelete(r)}>🗑</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<SharedTable rows={rows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
);
}

View file

@ -1,6 +1,231 @@
/* CSS for modals, table, and controls – minimal to work with existing global styles */
.contactTable { width:100%; border-collapse:collapse; }
.contactTable th, .contactTable td { border:1px solid #ddd; padding:8px; }
/* Modal styles for Follow-ups module */
.modalRoot{position:fixed;inset:0;z-index:1000;}
.modalScrim{position:absolute;inset:0;background:rgba(0,0,0,.3);}
.modalCard{position:relative;margin:10% auto;padding:20px;background:#fff;width:400px;}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,41 @@
import { useState, useEffect } from "react";
import { getFollowUps } from "../api";
const useFollowUps = () => {
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const reload = async () => {
setLoading(true);
try {
const followups = await getFollowUps();
let filtered = followups;
if (search) {
filtered = followups.filter((f) =>
f.workOrder.toLowerCase().includes(search.toLowerCase()) ||
f.reason.toLowerCase().includes(search.toLowerCase())
);
}
setTotalCount(filtered.length);
const start = (page - 1) * pageSize;
setRows(filtered.slice(start, start + pageSize));
} catch (error) {
setErr(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
export default useFollowUps;

View file

@ -0,0 +1,73 @@
// Mock locations data
const mockLocations = [
{
id: 1,
name: "Springfield Office",
address: "123 Main St",
city: "Springfield",
state: "IL",
zipCode: "62701",
phone: "(555) 123-4567",
contact: "Jane Smith",
contactEmail: "jane.smith@company.com",
status: "Active",
accountId: "ACC001",
},
{
id: 2,
name: "Chicago Distribution Center",
address: "456 Oak Ave",
city: "Chicago",
state: "IL",
zipCode: "60601",
phone: "(555) 234-5678",
contact: "Bob Wilson",
contactEmail: "bob.wilson@company.com",
status: "Active",
accountId: "ACC002",
},
{
id: 3,
name: "Indianapolis Warehouse",
address: "789 Pine Rd",
city: "Indianapolis",
state: "IN",
zipCode: "46201",
phone: "(555) 345-6789",
contact: "Alice Brown",
contactEmail: "alice.brown@company.com",
status: "Active",
accountId: "ACC003",
},
];
export const getLocations = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockLocations];
};
export const getLocationById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockLocations.find(l => l.id === parseInt(id));
};
export const addLocation = async (location) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newLocation = { id: mockLocations.length + 1, ...location };
mockLocations.push(newLocation);
return newLocation;
};
export const editLocation = async (location) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockLocations.findIndex(l => l.id === location.id);
if (idx >= 0) mockLocations[idx] = location;
return location;
};
export const deleteLocation = async (id) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockLocations.findIndex(l => l.id === id);
if (idx >= 0) mockLocations.splice(idx, 1);
return true;
};

View file

@ -0,0 +1,253 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getLocationById, addLocation, editLocation } from "../api";
export default function LocationFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [formData, setFormData] = useState({
name: "",
address: "",
city: "",
state: "IL",
zipCode: "",
phone: "",
contact: "",
contactEmail: "",
status: "Active",
accountId: "",
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isEdit && id) {
const loadLocation = async () => {
try {
const location = await getLocationById(id);
if (location) {
setFormData(location);
}
} catch (err) {
console.error("Failed to load location", err);
toast.error("Failed to load location");
}
};
loadLocation();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSave = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (isEdit) {
await editLocation(formData);
toast.success("Location updated successfully!");
} else {
await addLocation(formData);
toast.success("Location created successfully!");
}
navigate("/locations");
} catch (err) {
console.error(err);
toast.error("Failed to save location");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/locations");
};
return (
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit Location" : "Create Location"}
</div>
<form onSubmit={handleSave} className="formWrapper">
<div className="card formCard">
<div className="formBody">
{/* BASIC INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Location Information</div>
<div className="formRow">
<div className="formGroup">
<label>Name *</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
placeholder="Location name"
/>
</div>
<div className="formGroup">
<label>Account</label>
<input
type="text"
name="accountId"
value={formData.accountId}
onChange={handleChange}
placeholder="Account ID or name"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>Address *</label>
<input
type="text"
name="address"
value={formData.address}
onChange={handleChange}
required
placeholder="Street address"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>City *</label>
<input
type="text"
name="city"
value={formData.city}
onChange={handleChange}
required
placeholder="City"
/>
</div>
<div className="formGroup">
<label>State *</label>
<select
name="state"
value={formData.state}
onChange={handleChange}
required
>
<option value="IL">Illinois</option>
<option value="IN">Indiana</option>
<option value="MI">Michigan</option>
<option value="OH">Ohio</option>
<option value="WI">Wisconsin</option>
</select>
</div>
<div className="formGroup">
<label>Zip Code *</label>
<input
type="text"
name="zipCode"
value={formData.zipCode}
onChange={handleChange}
required
placeholder="12345"
/>
</div>
</div>
</div>
{/* CONTACT INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Contact Information</div>
<div className="formRow">
<div className="formGroup">
<label>Phone *</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
required
placeholder="(555) 000-0000"
/>
</div>
<div className="formGroup">
<label>Contact Person *</label>
<input
type="text"
name="contact"
value={formData.contact}
onChange={handleChange}
required
placeholder="Full name"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>Contact Email *</label>
<input
type="email"
name="contactEmail"
value={formData.contactEmail}
onChange={handleChange}
required
placeholder="email@company.com"
/>
</div>
</div>
</div>
{/* STATUS SECTION */}
<div className="formSection">
<div className="sectionTitle">Status</div>
<div className="formRow">
<div className="formGroup">
<label>Status *</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
required
>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Closed">Closed</option>
</select>
</div>
</div>
</div>
</div>
{/* ACTION BUTTONS */}
<div className="formActions">
<button
type="button"
className="btnSecondary"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btnPrimary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</div>
</form>
</section>
);
}

View file

@ -0,0 +1,156 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import useLocations from "./useLocations";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import ActionBar from "../../../components/ActionBar";
import { deleteLocation } from "../api";
import "./styles.css";
export default function LocationsList() {
const navigate = useNavigate();
const {
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload
} = useLocations();
const [viewMode, setViewMode] = useState("list");
const [delState, setDelState] = useState({ open: false, item: null, loading: false });
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const handleDeleteConfirm = async () => {
setDelState(prev => ({ ...prev, loading: true }));
try {
await deleteLocation(delState.item.id);
toast.success("Location deleted successfully!");
setDelState({ open: false, item: null, loading: false });
reload();
} catch (err) {
console.error(err);
toast.error("Failed to delete location");
setDelState(prev => ({ ...prev, loading: false }));
}
};
return (
<>
<ActionBar
moduleName="Locations"
moduleIcon="fa-map-marker"
onNew={() => navigate("/locations/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">Locations</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-map-marker fa-stack-1x fa-inverse"></i>
</span>
<span>Search for Locations</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="Name">
<option>Name</option>
<option>City</option>
<option>State</option>
<option>Phone</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
<i className="fa fa-navicon"></i>
</button>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
</div>
<Table
loading={loading}
error={err}
rows={rows}
onEdit={(item) => navigate(`/locations/edit/${item.id}`)}
onDelete={(item) => setDelState({ open: true, item, loading: false })}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
totalPages={totalPages}
/>
<DeleteModal
isOpen={delState.open}
onClose={() => setDelState({ open: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete Location"
message={`Are you sure you want to delete "${delState.item?.name}"?`}
loading={delState.loading}
/>
</section>
</>
);
}

View file

@ -0,0 +1,18 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete, page, setPage, pageSize, setPageSize, totalCount, totalPages }) {
const columns = [
{ key: "name", label: "Location Name", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "address", label: "Address" },
{ key: "city", label: "City" },
{ key: "state", label: "State" },
{ key: "phone", label: "Phone" },
{ key: "contactPerson", label: "Contact" },
{ key: "status", label: "Status" },
];
return (
<SharedTable rows={rows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
);
}

View file

@ -0,0 +1,231 @@
/* Modal styles for Locations module */
.modalRoot{position:fixed;inset:0;z-index:1000;}
.modalScrim{position:absolute;inset:0;background:rgba(0,0,0,.3);}
.modalCard{position:relative;margin:10% auto;padding:20px;background:#fff;width:400px;}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { getLocations } from "../api";
const useLocations = () => {
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const reload = async () => {
setLoading(true);
try {
const locations = await getLocations();
let filtered = locations;
if (search) {
filtered = locations.filter((l) =>
l.name.toLowerCase().includes(search.toLowerCase()) ||
l.city.toLowerCase().includes(search.toLowerCase()) ||
l.phone.includes(search)
);
}
setTotalCount(filtered.length);
const start = (page - 1) * pageSize;
setRows(filtered.slice(start, start + pageSize));
} catch (error) {
setErr(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
export default useLocations;

View file

@ -0,0 +1,73 @@
// Mock work orders data
const mockWorkOrders = [
{
id: 1,
workOrderNumber: "WO-001",
title: "HVAC System Maintenance",
location: "Springfield Office",
priority: "High",
status: "In Progress",
createdDate: "2024-01-15",
dueDate: "2024-01-22",
assignedTo: "John Anderson",
description: "Quarterly maintenance for HVAC system including filter replacement and inspection",
estimatedHours: 4,
},
{
id: 2,
workOrderNumber: "WO-002",
title: "Electrical Repair",
location: "Chicago Distribution Center",
priority: "Medium",
status: "Open",
createdDate: "2024-01-16",
dueDate: "2024-01-25",
assignedTo: "Sarah Martinez",
description: "Fix faulty outlet in warehouse section C",
estimatedHours: 2,
},
{
id: 3,
workOrderNumber: "WO-003",
title: "Plumbing Issue",
location: "Indianapolis Warehouse",
priority: "Low",
status: "Completed",
createdDate: "2024-01-10",
dueDate: "2024-01-20",
assignedTo: "Mike Johnson",
description: "Repair leaking pipe in bathroom",
estimatedHours: 3,
},
];
export const getWorkOrders = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockWorkOrders];
};
export const getWorkOrderById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockWorkOrders.find(w => w.id === parseInt(id));
};
export const addWorkOrder = async (workOrder) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newWorkOrder = { id: mockWorkOrders.length + 1, ...workOrder };
mockWorkOrders.push(newWorkOrder);
return newWorkOrder;
};
export const editWorkOrder = async (workOrder) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockWorkOrders.findIndex(w => w.id === workOrder.id);
if (idx >= 0) mockWorkOrders[idx] = workOrder;
return workOrder;
};
export const deleteWorkOrder = async (id) => {
await new Promise(resolve => setTimeout(resolve, 500));
const idx = mockWorkOrders.findIndex(w => w.id === id);
if (idx >= 0) mockWorkOrders.splice(idx, 1);
return true;
};

View file

@ -0,0 +1,262 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById, addWorkOrder, editWorkOrder } from "../api";
export default function WorkOrderFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [formData, setFormData] = useState({
workOrderNumber: "",
title: "",
location: "",
priority: "Medium",
status: "Open",
createdDate: new Date().toISOString().split("T")[0],
dueDate: "",
assignedTo: "",
description: "",
estimatedHours: 1,
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isEdit && id) {
const loadWorkOrder = async () => {
try {
const wo = await getWorkOrderById(id);
if (wo) {
setFormData(wo);
}
} catch (err) {
console.error("Failed to load work order", err);
toast.error("Failed to load work order");
}
};
loadWorkOrder();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSave = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (isEdit) {
await editWorkOrder(formData);
toast.success("Work Order updated successfully!");
} else {
await addWorkOrder(formData);
toast.success("Work Order created successfully!");
}
navigate("/workorders");
} catch (err) {
console.error(err);
toast.error("Failed to save work order");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/workorders");
};
return (
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit Work Order" : "Create Work Order"}
</div>
<form onSubmit={handleSave} className="formWrapper">
<div className="card formCard">
<div className="formBody">
{/* BASIC INFO SECTION */}
<div className="formSection">
<div className="sectionTitle">Work Order Details</div>
<div className="formRow">
<div className="formGroup">
<label>Work Order Number *</label>
<input
type="text"
name="workOrderNumber"
value={formData.workOrderNumber}
onChange={handleChange}
required
placeholder="e.g., WO-001"
/>
</div>
<div className="formGroup">
<label>Title *</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
required
placeholder="Work order title"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>Location *</label>
<select
name="location"
value={formData.location}
onChange={handleChange}
required
>
<option value="">Select Location</option>
<option value="Springfield Office">Springfield Office</option>
<option value="Chicago Distribution Center">Chicago Distribution Center</option>
<option value="Indianapolis Warehouse">Indianapolis Warehouse</option>
</select>
</div>
<div className="formGroup">
<label>Assigned To</label>
<select
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>
</select>
</div>
</div>
</div>
{/* DATES & PRIORITY SECTION */}
<div className="formSection">
<div className="sectionTitle">Schedule & Priority</div>
<div className="formRow">
<div className="formGroup">
<label>Created Date *</label>
<input
type="date"
name="createdDate"
value={formData.createdDate}
onChange={handleChange}
required
/>
</div>
<div className="formGroup">
<label>Due Date *</label>
<input
type="date"
name="dueDate"
value={formData.dueDate}
onChange={handleChange}
required
/>
</div>
<div className="formGroup">
<label>Estimated Hours *</label>
<input
type="number"
name="estimatedHours"
value={formData.estimatedHours}
onChange={handleChange}
required
min="0.5"
step="0.5"
/>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>Priority *</label>
<select
name="priority"
value={formData.priority}
onChange={handleChange}
required
>
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Urgent">Urgent</option>
</select>
</div>
<div className="formGroup">
<label>Status *</label>
<select
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>
<option value="Completed">Completed</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
{/* DESCRIPTION SECTION */}
<div className="formSection">
<div className="sectionTitle">Description</div>
<div className="formRow">
<div className="formGroup fullWidth">
<label>Description</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Detailed description of the work to be performed"
rows="5"
style={{
width: "100%",
padding: "10px",
borderRadius: "4px",
border: "1px solid #ddd",
fontFamily: "inherit",
}}
/>
</div>
</div>
</div>
</div>
{/* ACTION BUTTONS */}
<div className="formActions">
<button
type="button"
className="btnSecondary"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btnPrimary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</div>
</form>
</section>
);
}

View file

@ -0,0 +1,161 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import useWorkOrders from "./useWorkOrders";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import ActionBar from "../../../components/ActionBar";
import "./styles.css";
export default function WorkOrdersList() {
const navigate = useNavigate();
const {
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err
} = useWorkOrders();
const [viewMode, setViewMode] = useState("list");
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
item: null,
loading: false
});
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const handleDeleteClick = (item) => {
setDeleteModal({ isOpen: true, item, loading: false });
};
const handleDeleteConfirm = async () => {
setDeleteModal((prev) => ({ ...prev, loading: true }));
try {
await new Promise((resolve) => setTimeout(resolve, 500));
toast.success("Work Order deleted successfully!");
setDeleteModal({ isOpen: false, item: null, loading: false });
} catch {
toast.error("Failed to delete work order");
setDeleteModal((prev) => ({ ...prev, loading: false }));
}
};
return (
<>
<ActionBar
moduleName="Work Orders"
moduleIcon="fa-clipboard"
onNew={() => navigate("/workorders/new")}
onBulkEdit={() => console.log("Bulk edit")}
selectedCount={0}
/>
<section className="listPage">
<div className="breadcrumb">
<span className="breadcrumbItem active">Work Orders</span>
</div>
<div className="searchSection">
<div className="searchHeader">
<h3 className="searchHeading">
<span className="fa-stack iconStack">
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className="fa fa-clipboard fa-stack-1x fa-inverse"></i>
</span>
<span>Search for Work Orders</span>
</h3>
<span className="searchSubheading">Use the settings menu to adjust how to filter the list, and which columns to display.</span>
</div>
<div className="searchControls">
<div className="searchLeft">
<div className="searchInputGroup">
<select className="searchDropdown" defaultValue="Title">
<option>Title</option>
<option>WO #</option>
<option>Location</option>
<option>Status</option>
</select>
<input
className="searchInput"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Enter search criteria here..."
/>
<button className="searchButton">Search!</button>
</div>
<div className="viewToggle">
<button
className={`viewBtn ${viewMode === "list" ? "active" : ""}`}
title="List view"
onClick={() => setViewMode("list")}
>
<i className="fa fa-navicon"></i>
</button>
<button
className={`viewBtn ${viewMode === "card" ? "active" : ""}`}
title="Card view"
onClick={() => setViewMode("card")}
>
<i className="fa fa-th-large"></i>
</button>
</div>
</div>
<div className="searchRight">
<div className="paginationTop">
<button
className="pageBtn"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
«
</button>
<span className="pageNumber">{page}</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
»
</button>
</div>
</div>
</div>
</div>
<Table
loading={loading}
error={err}
rows={rows}
onEdit={(item) => navigate(`/workorders/edit/${item.id}`)}
onDelete={handleDeleteClick}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
totalPages={totalPages}
/>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() =>
setDeleteModal({ isOpen: false, item: null, loading: false })
}
onConfirm={handleDeleteConfirm}
title="Delete Work Order"
message={`Are you sure you want to delete "${deleteModal.item?.title}"? This action cannot be undone.`}
loading={deleteModal.loading}
/>
</section>
</>
);
}

View file

@ -0,0 +1,18 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
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: "createdDate", label: "Created" },
{ key: "assignedTo", label: "Assigned To" },
];
return (
<SharedTable rows={rows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
);
}

View file

@ -0,0 +1,247 @@
/* Modal styles for Work Orders module */
.modalRoot {
position: fixed;
inset: 0;
z-index: 1000;
}
.modalScrim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.3);
}
.modalCard {
position: relative;
margin: 10% auto;
padding: 20px;
background: #fff;
width: 400px;
}
/* Override listPage padding for this module */
.listPage {
padding: 0 !important;
background: white !important;
min-height: auto !important;
}
/* Breadcrumb */
.breadcrumb {
background: white;
padding: 10px 20px;
margin: 0;
border-bottom: 1px solid #e5e5e5;
font-size: 13px;
}
.breadcrumbItem {
color: #333;
}
.breadcrumbItem.active {
cursor: default;
font-weight: bold;
color: #00a0d1;
}
/* Search Section Styles */
.searchSection {
background: white;
padding: 20px;
margin-bottom: 0;
border-bottom: 1px solid #e5e5e5;
position: relative;
z-index: 1;
}
.searchHeader {
margin-bottom: 20px;
}
.searchHeading {
font-size: 24px;
font-weight: 300;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 10px;
}
.iconStack {
font-size: 0.9em;
}
.text-primary {
color: #0088cc;
}
.fa-inverse {
color: white;
}
.searchSubheading {
color: #666;
font-size: 14px;
display: block;
margin-left: 45px;
}
.searchControls {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.searchLeft {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.searchInputGroup {
display: flex;
align-items: center;
gap: 0;
flex: 1;
max-width: 600px;
}
.searchDropdown {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
background: white;
font-size: 14px;
min-width: 120px;
}
.searchInput {
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-left: none;
border-right: 1px solid #ccc;
flex: 1;
font-size: 14px;
outline: none;
}
.searchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.searchButton {
height: 38px;
padding: 6px 20px;
background: #0088cc;
color: white;
border: 1px solid #0088cc;
border-radius: 0 4px 4px 0;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
margin-left: 20px;
}
.searchButton:hover {
background: #0077b3;
border-color: #0077b3;
}
.viewToggle {
display: flex;
border: 1px solid #0088cc;
border-radius: 4px;
overflow: hidden;
}
.viewBtn {
width: 40px;
height: 38px;
background: white;
border: none;
border-right: 1px solid #0088cc;
color: #0088cc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.viewBtn:last-child {
border-right: none;
}
.viewBtn.active {
background: #0088cc;
color: white;
}
.viewBtn:hover:not(.active) {
background: #e6f2f8;
}
.searchRight {
display: flex;
align-items: center;
}
.paginationTop {
display: flex;
align-items: center;
gap: 5px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0;
}
.pageBtn {
width: 35px;
height: 36px;
background: white;
border: none;
cursor: pointer;
font-size: 14px;
color: #0088cc;
display: flex;
align-items: center;
justify-content: center;
}
.pageBtn:hover:not(:disabled) {
background: #e6f2f8;
}
.pageBtn:disabled {
color: #ccc;
cursor: not-allowed;
}
.pageNumber {
min-width: 35px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #0088cc;
color: white;
font-size: 14px;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
}
/* Table wrapper */
.tableContainer {
background: white;
border: 1px solid #ddd;
border-radius: 0;
margin: 0;
}

View file

@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { getWorkOrders } from "../api";
const useWorkOrders = () => {
const [rows, setRows] = useState([]);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const reload = async () => {
setLoading(true);
try {
const workorders = await getWorkOrders();
let filtered = workorders;
if (search) {
filtered = workorders.filter((w) =>
w.title.toLowerCase().includes(search.toLowerCase()) ||
w.number.toLowerCase().includes(search.toLowerCase()) ||
w.location.toLowerCase().includes(search.toLowerCase())
);
}
setTotalCount(filtered.length);
const start = (page - 1) * pageSize;
setRows(filtered.slice(start, start + pageSize));
} catch (error) {
setErr(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
export default useWorkOrders;

View file

@ -4,4 +4,396 @@
margin: 0;
padding: 0;
z-index: 0;
}
box-sizing: border-box;
}
body {
font-family: "Nunito", sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.5;
}
/* Page Layout */
.listPage, .contactsPage {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.pageTitle {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}
.card, .controlsCard {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
/* Controls */
.controlsTop {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #eee;
}
.leftControls {
flex: 1;
}
.filter {
display: flex;
gap: 10px;
align-items: center;
}
.filter select, .filter input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.filter input {
flex: 1;
min-width: 300px;
}
.searchBtn {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.searchBtn:hover {
background: #0056b3;
}
.rightControls {
display: flex;
gap: 8px;
}
.iconSmall {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.iconSmall:hover {
background: #f0f0f0;
border-color: #999;
}
/* Table Styles */
.tableContainer {
padding: 15px;
}
.tableResponsive {
overflow-x: auto;
}
.dataTable {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.dataTable thead {
background: #f9f9f9;
}
.dataTable th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #ddd;
}
.dataTable td {
padding: 12px;
border-bottom: 1px solid #eee;
color: #333;
}
.dataTable tbody tr:hover {
background: #f9f9f9;
}
.dataTable a {
color: #007bff;
text-decoration: none;
cursor: pointer;
}
.dataTable a:hover {
text-decoration: underline;
}
.actionButtons {
display: flex;
gap: 8px;
}
.btnIcon {
padding: 4px 8px;
background: white;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btnIcon:hover {
background: #f0f0f0;
border-color: #999;
}
.btnIcon.edit {
color: #28a745;
}
.btnIcon.delete {
color: #dc3545;
}
/* Pagination */
.paginationContainer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-top: 1px solid #eee;
font-size: 13px;
}
.paginationLeft, .paginationRight {
display: flex;
align-items: center;
gap: 15px;
}
.pagination {
display: flex;
gap: 8px;
align-items: center;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.pagination button:hover:not(:disabled) {
background: #f0f0f0;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Form Styles */
.formWrapper {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.formCard {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
max-width: 900px;
margin: 0 auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.formHeader {
padding: 20px;
border-bottom: 1px solid #ddd;
background: #f9f9f9;
}
.formHeader h2 {
margin: 0;
font-size: 22px;
color: #333;
}
.dataForm {
padding: 20px;
}
.section {
margin-bottom: 25px;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
border-bottom: 2px solid #007bff;
padding-bottom: 8px;
}
.formBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.formGroup {
display: flex;
flex-direction: column;
}
.formLabel {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
color: #333;
}
.required {
color: #dc3545;
}
.formControl {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.formControl:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
}
.formActions {
display: flex;
gap: 12px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.btnPrimary {
padding: 10px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btnPrimary:hover {
background: #0056b3;
}
.btnPrimary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btnSecondary {
padding: 10px 24px;
background: white;
color: #333;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btnSecondary:hover {
background: #f0f0f0;
}
/* Loading State */
.loading {
padding: 40px;
text-align: center;
color: #666;
font-size: 16px;
}
.error {
padding: 15px;
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
border-radius: 4px;
margin: 15px;
}
/* Responsive */
@media (max-width: 768px) {
.controlsTop {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.filter {
flex-direction: column;
}
.filter input, .filter select {
width: 100%;
}
.dataTable {
font-size: 12px;
}
.dataTable th, .dataTable td {
padding: 8px;
}
.paginationContainer {
flex-direction: column;
gap: 12px;
}
.formBody {
grid-template-columns: 1fr;
}
}