diff --git a/.gitignore b/.gitignore index d6c577a6..b5148e25 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ .env.test.local .env.production.local +# markdown files +*.md + npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/public/index.html b/public/index.html index aa069f27..42040d91 100644 --- a/public/index.html +++ b/public/index.html @@ -15,6 +15,10 @@ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> + + + + - React App + Seahaven - Officetrax diff --git a/src/App.js b/src/App.js index c06f9a32..8d762ba0 100644 --- a/src/App.js +++ b/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() {
+ } /> + } /> - {/* CONTACTS ROUTES */} + {/* CONTACTS */} } /> } /> - } /> + } /> - {/* ACCOUNTS ROUTES */} + {/* ACCOUNTS */} } /> - } /> - } /> + } /> + } /> - {/* EMPLOYEES ROUTES */} + {/* EMPLOYEES */} } /> - } /> - } /> + } /> + } /> + + {/* LOCATIONS */} + } /> + } /> + } /> + + {/* WORK ORDERS */} + } /> + } /> + } /> {/* FOLLOW UPS */} } /> - } /> - } /> + } /> + } /> {/* PM SCHEDULES */} } /> - } /> - } /> - + } /> + } />
diff --git a/src/components/ActionBar.css b/src/components/ActionBar.css new file mode 100644 index 00000000..b373120f --- /dev/null +++ b/src/components/ActionBar.css @@ -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; +} + diff --git a/src/components/ActionBar.js b/src/components/ActionBar.js new file mode 100644 index 00000000..92a27d3e --- /dev/null +++ b/src/components/ActionBar.js @@ -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 ( +
+ +
+ ); +} + diff --git a/src/components/DeleteModal.js b/src/components/DeleteModal.js new file mode 100644 index 00000000..8e141e33 --- /dev/null +++ b/src/components/DeleteModal.js @@ -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 ( +
+
+
+

{title}

+ +
+ +
+

{message}

+
+ +
+ + +
+
+
+ ); +} + +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", + }, +}; diff --git a/src/components/SharedTable.js b/src/components/SharedTable.js new file mode 100644 index 00000000..08f730c8 --- /dev/null +++ b/src/components/SharedTable.js @@ -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
Loading...
; + if (error) return
{error}
; + + return ( +
+
+ + + + + {columns.map((col) => ( + + ))} + + + + + {rows.map((row, idx) => ( + + + {columns.map((col) => ( + + ))} + + + ))} + +
+ + + {col.label} + Actions
+ + + {col.render + ? col.render(row[col.key], row) + : row[col.key]} + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ + {/* Pagination */} +
+
+ +
+
+ + Showing {Math.min(page * pageSize, totalCount)} of {totalCount} + +
+ + Page {page} of {totalPages} + +
+
+
+
+ ); +}; + +export default SharedTable; diff --git a/src/index.js b/src/index.js index 59ae987b..793bc2b9 100644 --- a/src/index.js +++ b/src/index.js @@ -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( diff --git a/src/pages/Dashboard.css b/src/pages/Dashboard.css new file mode 100644 index 00000000..16586217 --- /dev/null +++ b/src/pages/Dashboard.css @@ -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; +} diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js new file mode 100644 index 00000000..6120c9a0 --- /dev/null +++ b/src/pages/Dashboard.js @@ -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 ( +
+
Dashboard
+ + {/* KPI Cards */} +
+ {kpis.map((kpi, idx) => ( +
+
{kpi.icon}
+
+
{kpi.title}
+ {kpi.stat &&
{kpi.stat}
} + {kpi.desc &&

{kpi.desc}

} +
+
+ ))} +
+ + {/* Priority Analysis */} +
+
+

Priority Analysis

+

Age of open Work Orders by Priority

+
+
+ + + + + + + + + + + + + {priorities.map((row, idx) => ( + + + + + + + + + ))} + +
PriorityTotal0-14 Days15-29 Days30-59 Days60+ Days
{row.priority}{row.total}{row["0-14"]}{row["15-29"]}{row["30-59"]}{row["60+"]}
+
+
+ + {/* Invoice Analysis */} +
+
+

Invoice Analysis

+
+
+ + + + + + + + + + {invoices.map((inv, idx) => ( + + + + + + ))} + +
StatusCountAmount
{inv.name}{inv.count}${inv.amount}
+
+
+
+ ); +}; + +export default Dashboard; diff --git a/src/pages/PmSchedule/api.js b/src/pages/PmSchedule/api.js new file mode 100644 index 00000000..7113b361 --- /dev/null +++ b/src/pages/PmSchedule/api.js @@ -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; +}; diff --git a/src/pages/PmSchedule/form/PmScheduleFormPage.js b/src/pages/PmSchedule/form/PmScheduleFormPage.js index 26d13968..f4ef8148 100644 --- a/src/pages/PmSchedule/form/PmScheduleFormPage.js +++ b/src/pages/PmSchedule/form/PmScheduleFormPage.js @@ -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 ( -
-
-
-

{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}

-
+
+
+ {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} +
-
-
General Details
- -
-
- - + +
+
+ {/* ASSET INFO SECTION */} +
+
Asset Information
+
+
+ + +
+
+ + +
+
-
- - + {/* SCHEDULE INFO SECTION */} +
+
Schedule Information
+
+
+ + +
+
+ + +
+
+ + +
+
-
- - + {/* ASSIGNMENT SECTION */} +
+
Assignment
+
+
+ + +
+
+ + +
+
-
- -