mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
fixed style, added new pages, refactored
This commit is contained in:
parent
3f3dcc70f6
commit
933e93c845
55 changed files with 6754 additions and 1131 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -19,6 +19,9 @@
|
|||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# markdown files
|
||||
*.md
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
59
src/App.js
59
src/App.js
|
|
@ -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>
|
||||
|
|
|
|||
122
src/components/ActionBar.css
Normal file
122
src/components/ActionBar.css
Normal 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
164
src/components/ActionBar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
141
src/components/DeleteModal.js
Normal file
141
src/components/DeleteModal.js
Normal 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",
|
||||
},
|
||||
};
|
||||
121
src/components/SharedTable.js
Normal file
121
src/components/SharedTable.js
Normal 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;
|
||||
|
|
@ -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
116
src/pages/Dashboard.css
Normal 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
109
src/pages/Dashboard.js
Normal 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;
|
||||
67
src/pages/PmSchedule/api.js
Normal file
67
src/pages/PmSchedule/api.js
Normal 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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
41
src/pages/PmSchedule/list/usePMSchedules.js
Normal file
41
src/pages/PmSchedule/list/usePMSchedules.js
Normal 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
66
src/pages/accounts/api.js
Normal 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;
|
||||
};
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
42
src/pages/accounts/list/useAccounts.js
Normal file
42
src/pages/accounts/list/useAccounts.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
70
src/pages/dashboard/Dashboard.css
Normal file
70
src/pages/dashboard/Dashboard.css
Normal 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);
|
||||
}
|
||||
}
|
||||
130
src/pages/dashboard/Dashboard.js
Normal file
130
src/pages/dashboard/Dashboard.js
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/pages/dashboard/api.js
Normal file
29
src/pages/dashboard/api.js
Normal 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 },
|
||||
];
|
||||
};
|
||||
94
src/pages/employees/api.js
Normal file
94
src/pages/employees/api.js
Normal 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;
|
||||
};
|
||||
418
src/pages/employees/form/EmployeeFormPage.css
Normal file
418
src/pages/employees/form/EmployeeFormPage.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 & 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 & 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
42
src/pages/employees/list/useEmployees.js
Normal file
42
src/pages/employees/list/useEmployees.js
Normal 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
64
src/pages/followup/api.js
Normal 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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
41
src/pages/followup/list/useFollowUps.js
Normal file
41
src/pages/followup/list/useFollowUps.js
Normal 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;
|
||||
73
src/pages/locations/api.js
Normal file
73
src/pages/locations/api.js
Normal 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;
|
||||
};
|
||||
253
src/pages/locations/form/LocationFormPage.js
Normal file
253
src/pages/locations/form/LocationFormPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/pages/locations/list/List.js
Normal file
156
src/pages/locations/list/List.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
src/pages/locations/list/Table.js
Normal file
18
src/pages/locations/list/Table.js
Normal 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} />
|
||||
);
|
||||
}
|
||||
231
src/pages/locations/list/styles.css
Normal file
231
src/pages/locations/list/styles.css
Normal 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;
|
||||
}
|
||||
42
src/pages/locations/list/useLocations.js
Normal file
42
src/pages/locations/list/useLocations.js
Normal 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;
|
||||
73
src/pages/workorders/api.js
Normal file
73
src/pages/workorders/api.js
Normal 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;
|
||||
};
|
||||
262
src/pages/workorders/form/WorkOrderFormPage.js
Normal file
262
src/pages/workorders/form/WorkOrderFormPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/pages/workorders/list/List.js
Normal file
161
src/pages/workorders/list/List.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
src/pages/workorders/list/Table.js
Normal file
18
src/pages/workorders/list/Table.js
Normal 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} />
|
||||
);
|
||||
}
|
||||
247
src/pages/workorders/list/styles.css
Normal file
247
src/pages/workorders/list/styles.css
Normal 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;
|
||||
}
|
||||
42
src/pages/workorders/list/useWorkOrders.js
Normal file
42
src/pages/workorders/list/useWorkOrders.js
Normal 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;
|
||||
394
src/site.css
394
src/site.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue