UI changes checking in

This commit is contained in:
npalOmega 2026-03-30 07:53:42 -05:00
parent 8417d708ac
commit 65ec768e13
55 changed files with 6130 additions and 1132 deletions

4
.env Normal file
View file

@ -0,0 +1,4 @@
# Backend API Base URL (without /api suffix)
# The /api prefix is added in the service files
REACT_APP_API_URL=https://localhost:7195

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
# API Configuration
# Copy this file to .env and update with your actual values
# Backend API Base URL (without /api suffix)
# For local development, this should point to your ASP.NET Core API
REACT_APP_API_URL=http://localhost:5000
# For production, update to your production API URL
# REACT_APP_API_URL=https://api.seahavenindustries.com
# Note: The /api prefix is added in the service files
# Example: ${REACT_APP_API_URL}/api/Authentication/login

3
.gitignore vendored
View file

@ -25,3 +25,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
db.txt
seed-data.sql
/backend

115
package-lock.json generated
View file

@ -10,6 +10,10 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@testing-library/dom": "^10.4.1",
@ -2451,6 +2455,52 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/react": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz",
"integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20",
"react": "^16.7.0 || ^17 || ^18 || ^19",
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
}
},
"node_modules/@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"dependencies": {
"@fullcalendar/daygrid": "~6.1.20"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -12943,6 +12993,16 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -15646,16 +15706,16 @@
}
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": {
@ -18332,6 +18392,41 @@
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="
},
"@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"peer": true,
"requires": {
"preact": "~10.12.1"
}
},
"@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
"requires": {}
},
"@fullcalendar/interaction": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"requires": {}
},
"@fullcalendar/react": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz",
"integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==",
"requires": {}
},
"@fullcalendar/timegrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
"requires": {
"@fullcalendar/daygrid": "~6.1.20"
}
},
"@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -25654,6 +25749,12 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"peer": true
},
"prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -27628,9 +27729,9 @@
}
},
"typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"peer": true
},
"unbox-primitive": {

View file

@ -5,6 +5,10 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@testing-library/dom": "^10.4.1",

BIN
public/BrandingImage.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -1,10 +1,14 @@
import React, { useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Sidebar from "./components/Sidebar";
import Topbar from "./components/Topbar";
import Dashboard from "./pages/Dashboard";
// AUTH
import LoginPage from "./pages/auth/LoginPage";
import { isAuthenticated } from "./services/authService";
// CONTACTS
import ContactsList from "./pages/contacts/list/List";
import ContactFormPage from "./pages/contacts/form/ContactFormPage";
@ -33,60 +37,96 @@ import FollowUpFormPage from "./pages/followup/form/FollowUpFormPage";
import PMScheduleList from "./pages/PmSchedule/list/List";
import PMScheduleFormPage from "./pages/PmSchedule/form/PmScheduleFormPage";
// ASSETS
import AssetsList from "./pages/assets/list/List";
import AssetFormPage from "./pages/assets/form/AssetFormPage";
// CALENDAR
import CalendarPage from "./pages/calendar/list/CalendarPage";
import EventFormPage from "./pages/calendar/form/EventFormPage";
// Protected Route Component
function ProtectedRoute({ children }) {
return isAuthenticated() ? children : <Navigate to="/login" replace />;
}
export default function App() {
const [navCollapsed, setNavCollapsed] = useState(false);
return (
<BrowserRouter>
<div className={`app ${navCollapsed ? "nav-collapsed" : ""}`}>
<Sidebar />
<Routes>
{/* Public Routes */}
<Route path="/login" element={<LoginPage />} />
<div className="mainArea">
<Topbar onToggleNav={() => setNavCollapsed(v => !v)} />
{/* Protected Routes */}
<Route
path="/*"
element={
<ProtectedRoute>
<div className={`app ${navCollapsed ? "nav-collapsed" : ""}`}>
<Sidebar />
<div className="contentWrapper">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<div className="mainArea">
<Topbar onToggleNav={() => setNavCollapsed(v => !v)} />
{/* CONTACTS */}
<Route path="/contacts" element={<ContactsList />} />
<Route path="/contacts/new" element={<ContactFormPage />} />
<Route path="/contacts/edit/:id" element={<ContactFormPage />} />
<div className="contentWrapper">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* ACCOUNTS */}
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/new" element={<AccountFormPage />} />
<Route path="/accounts/edit/:id" element={<AccountFormPage />} />
{/* CONTACTS */}
<Route path="/contacts" element={<ContactsList />} />
<Route path="/contacts/new" element={<ContactFormPage />} />
<Route path="/contacts/edit/:id" element={<ContactFormPage />} />
{/* EMPLOYEES */}
<Route path="/employees" element={<EmployeeList />} />
<Route path="/employees/new" element={<EmployeeFormPage />} />
<Route path="/employees/edit/:id" element={<EmployeeFormPage />} />
{/* ACCOUNTS */}
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/new" element={<AccountFormPage />} />
<Route path="/accounts/edit/:id" element={<AccountFormPage />} />
{/* LOCATIONS */}
<Route path="/locations" element={<LocationsList />} />
<Route path="/locations/new" element={<LocationFormPage />} />
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
{/* EMPLOYEES */}
<Route path="/employees" element={<EmployeeList />} />
<Route path="/employees/new" element={<EmployeeFormPage />} />
<Route path="/employees/edit/:id" element={<EmployeeFormPage />} />
{/* WORK ORDERS */}
<Route path="/workorders" element={<WorkOrdersList />} />
<Route path="/workorders/new" element={<WorkOrderFormPage />} />
<Route path="/workorders/edit/:id" element={<WorkOrderFormPage />} />
{/* LOCATIONS */}
<Route path="/locations" element={<LocationsList />} />
<Route path="/locations/new" element={<LocationFormPage />} />
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
{/* FOLLOW UPS */}
<Route path="/followups" element={<FollowUpList />} />
<Route path="/followups/new" element={<FollowUpFormPage />} />
<Route path="/followups/edit/:id" element={<FollowUpFormPage />} />
{/* WORK ORDERS */}
<Route path="/workorders" element={<WorkOrdersList />} />
<Route path="/workorders/new" element={<WorkOrderFormPage />} />
<Route path="/workorders/edit/:id" element={<WorkOrderFormPage />} />
{/* PM SCHEDULES */}
<Route path="/pmschedules" element={<PMScheduleList />} />
<Route path="/pmschedules/new" element={<PMScheduleFormPage />} />
<Route path="/pmschedules/edit/:id" element={<PMScheduleFormPage />} />
</Routes>
</div>
</div>
</div>
{/* FOLLOW UPS */}
<Route path="/followups" element={<FollowUpList />} />
<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={<PMScheduleFormPage />} />
<Route path="/pmschedules/edit/:id" element={<PMScheduleFormPage />} />
{/* ASSETS */}
<Route path="/assets" element={<AssetsList />} />
<Route path="/assets/new" element={<AssetFormPage />} />
<Route path="/assets/edit/:id" element={<AssetFormPage />} />
{/* CALENDAR */}
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/calendar/new" element={<EventFormPage />} />
<Route path="/calendar/edit/:id" element={<EventFormPage />} />
</Routes>
</div>
</div>
</div>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}

View file

@ -1,6 +1,25 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { logout, getCurrentUser } from "../services/authService";
export default function Topbar({ onToggleNav }) {
const navigate = useNavigate();
const currentUser = getCurrentUser();
const handleLogout = () => {
logout();
navigate("/login");
};
const getInitials = (fullname) => {
if (!fullname) return "U";
const parts = fullname.split(" ");
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return fullname.substring(0, 2).toUpperCase();
};
return (
<header className="topbar">
<div className="topLeft">
@ -22,8 +41,23 @@ export default function Topbar({ onToggleNav }) {
<div className="notif">
🔔<span className="dot">1</span>
</div>
<div className="username">Adam Moussa</div>
<div className="avatar">AM</div>
<div className="username">{currentUser?.fullname || "User"}</div>
<div className="avatar">{getInitials(currentUser?.fullname)}</div>
<button
onClick={handleLogout}
style={{
marginLeft: "15px",
padding: "6px 12px",
backgroundColor: "#dc3545",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "13px",
}}
>
Logout
</button>
</div>
</header>
);

View file

@ -0,0 +1,289 @@
/* PM Schedule Form Page - Matching Employee Form Style */
.pmScheduleFormPage {
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-4 {
width: 33.33333333%;
}
.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;
}
/* 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;
}
textarea.form-control {
height: auto;
min-height: 100px;
}
/* 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-default {
color: #333;
background-color: #fff;
border-color: #ccc;
}
.btn-default:hover {
background-color: #e6e6e6;
border-color: #adadad;
}
.btn-primary {
color: #fff;
background-color: #0088cc;
border-color: #0088cc;
}
.btn-primary:hover {
background-color: #006699;
border-color: #005580;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
/* Responsive */
@media (max-width: 767px) {
[class*="col-sm-"] {
width: 100%;
}
}

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getPMScheduleById, addPMSchedule, editPMSchedule } from "../api";
import "./PmScheduleFormPage.css";
export default function PmScheduleFormPage() {
const { id } = useParams();
@ -72,164 +73,225 @@ export default function PmScheduleFormPage() {
};
return (
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit PM Schedule" : "Create PM Schedule"}
</div>
<div className="pmScheduleFormPage">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); navigate("/pmschedules"); }}>
PM Schedules
</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<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 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 a PM Schedule" : "Create a PM Schedule"}</span>
</h3>
</div>
</div>
</div>
{/* 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>
{/* Asset Information Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Asset Information</strong>
</h5>
</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 className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="asset">Asset ID</label>
<input
className="form-control"
type="text"
id="asset"
name="asset"
value={formData.asset}
onChange={handleChange}
placeholder="e.g., HVAC-001"
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="assetName">Asset Name</label>
<input
className="form-control"
type="text"
id="assetName"
name="assetName"
value={formData.assetName}
onChange={handleChange}
placeholder="Asset description"
required
/>
</div>
</div>
</div>
</div>
</div>
{/* 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>
{/* Schedule Information Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Schedule Information</strong>
</h5>
</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 className="row">
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="frequency">Frequency</label>
<select
className="form-control"
id="frequency"
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>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="lastServiceDate">Last Service Date</label>
<input
className="form-control"
type="date"
id="lastServiceDate"
name="lastServiceDate"
value={formData.lastServiceDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="nextServiceDate">Next Service Date</label>
<input
className="form-control"
type="date"
id="nextServiceDate"
name="nextServiceDate"
value={formData.nextServiceDate}
onChange={handleChange}
required
/>
</div>
</div>
</div>
</div>
</div>
{/* 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",
}}
/>
{/* Assignment Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Assignment</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="assignedTo">Assigned To</label>
<select
className="form-control"
id="assignedTo"
name="assignedTo"
value={formData.assignedTo}
onChange={handleChange}
>
<option value="">Select Employee</option>
<option value="John Anderson">John Anderson</option>
<option value="Sarah Martinez">Sarah Martinez</option>
<option value="Mike Johnson">Mike Johnson</option>
</select>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="status">Status</label>
<select
className="form-control"
id="status"
name="status"
value={formData.status}
onChange={handleChange}
required
>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Suspended">Suspended</option>
</select>
</div>
</div>
</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>
{/* Notes Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Notes</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-12">
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea
className="form-control"
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows="4"
placeholder="PM schedule notes"
/>
</div>
</div>
</div>
</div>
{/* Form Actions */}
<div className="form-actions">
<button
type="button"
className="btn btn-default"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
</form>
</section>
</div>
</div>
);
}

View file

@ -8,23 +8,23 @@ export const fetchPmSchedulesPaged = async ({
pageSize = 12,
}) => {
const res = await get(
`${API_URL}/PmSchedule/GetList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`
`${API_URL}/PMSchedule/GetList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`
);
const rows = (res?.Data || []).map(x => ({
id: x.Id,
name: x.Name,
trade: x.Trade,
status: x.Status,
nextWorkOrderDate: x.NextWorkOrderDate,
owner: x.Owner,
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id ?? x.Id,
name: x.name ?? x.Name ?? "",
trade: x.trade ?? x.Trade ?? "",
status: x.status ?? x.Status ?? "",
nextWorkOrderDate: x.nextWorkOrderDate ?? x.NextWorkOrderDate ?? "",
owner: x.owner ?? x.Owner ?? "",
}));
return {
rows,
page: res.PageNumber,
pageSize: res.PageSize,
totalCount: res.TotalCount,
totalPages: res.TotalPages,
page: res?.pageNumber ?? res?.PageNumber ?? 1,
pageSize: res?.pageSize ?? res?.PageSize ?? pageSize,
totalCount: res?.totalCount ?? res?.TotalCount ?? rows.length,
totalPages: res?.totalPages ?? res?.TotalPages ?? 1,
};
};

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchContactsPaged } from "./api";
import { fetchPmSchedulesPaged } from "./api";
export default function useItems() {
const [rows, setRows] = useState([]);
@ -13,7 +13,7 @@ export default function useItems() {
const reload = useCallback(() => {
setLoading(true);
setErr("");
fetchContactsPaged({ search, page, pageSize })
fetchPmSchedulesPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { getPMSchedules } from "../api";
import { useState, useEffect, useCallback, useMemo } from "react";
import { fetchPmSchedulesPaged } from "./api";
const usePMSchedules = () => {
const [rows, setRows] = useState([]);
@ -7,35 +7,41 @@ const usePMSchedules = () => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const reload = async () => {
const reload = useCallback(() => {
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);
}
};
setErr("");
fetchPmSchedulesPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
useEffect(() => {
reload();
}, [search, page, pageSize]);
}, [reload]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
return useMemo(
() => ({
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload]
);
};
export default usePMSchedules;

View file

@ -1,66 +1,49 @@
// 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",
},
];
import { accountApi } from "../../services/api";
export const getAccounts = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockAccounts];
export const getAccounts = async (params = {}) => {
try {
const response = await accountApi.getAll(params);
const accounts = response.data || response;
return Array.isArray(accounts) ? accounts : [];
} catch (error) {
console.error("Error fetching accounts:", error);
throw error;
}
};
export const getAccountById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockAccounts.find(a => a.id === parseInt(id));
try {
return await accountApi.getById(id);
} catch (error) {
console.error("Error fetching account:", error);
throw error;
}
};
export const addAccount = async (account) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newAccount = { id: mockAccounts.length + 1, ...account };
mockAccounts.push(newAccount);
return newAccount;
try {
return await accountApi.create(account);
} catch (error) {
console.error("Error creating account:", error);
throw error;
}
};
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;
try {
return await accountApi.update(account.id, account);
} catch (error) {
console.error("Error updating account:", error);
throw error;
}
};
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;
try {
await accountApi.delete(id);
return true;
} catch (error) {
console.error("Error deleting account:", error);
throw error;
}
};

View file

@ -1,20 +1,9 @@
import { get } from "../../../apiUtil";
import { API_URL } from "../../../constants";
export const fetchAccountsPaged = async ({
search = "",
page = 1,
pageSize = 12,
}) => {
const res = await get(
`${API_URL}/Account/GetAccountList?search=${encodeURIComponent(
search
)}&page=${page}&pageSize=${pageSize}`
);
const data = res?.data || res?.Data || [];
const rows = data.map((x) => ({
export const fetchAccountsPaged = async ({ search = "", page = 1, pageSize = 12 }) => {
const res = await get(`${API_URL}/Account/GetAccountList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`);
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id ?? x.Id,
name: x.name ?? x.Name ?? x.AccountName ?? "",
number: x.number ?? x.AccountNumber ?? "",
@ -24,13 +13,12 @@ export const fetchAccountsPaged = async ({
phoneNumber: x.phoneNumber ?? x.Phone ?? x.PhoneNumber ?? "",
owner: x.owner ?? x.Owner ?? "",
}));
return {
rows,
page: res?.page ?? res?.PageNumber ?? page,
pageSize: res?.pageSize ?? res?.PageSize ?? pageSize,
totalCount: res?.totalCount ?? res?.TotalCount ?? rows.length,
totalPages: res?.totalPages ?? res?.TotalPages ?? 1,
page: res?.pageNumber ?? 1,
pageSize: res?.pageSize ?? pageSize,
totalCount: res?.totalCount ?? rows.length,
totalPages: res?.totalPages ?? 1,
};
};

View file

@ -1,42 +1,31 @@
import { useState, useEffect } from "react";
import { getAccounts } from "../api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchAccountsPaged } from "./api";
const useAccounts = () => {
export default function 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 [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const reload = async () => {
const reload = useCallback(() => {
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();
setErr("");
fetchAccountsPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
useEffect(() => { reload(); }, [reload]);
export default useAccounts;
return useMemo(() => ({
rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err, reload
}), [rows, search, page, pageSize, totalCount, loading, err, reload]);
}

View file

@ -0,0 +1,290 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
TextField,
Typography,
Paper,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
CircularProgress,
Alert,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SaveIcon from "@mui/icons-material/Save";
import { get, post } from "../../../apiUtil";
import { API_URL } from "../../../constants";
const AssetFormPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const [formData, setFormData] = useState({
assetCode: "",
name: "",
description: "",
accountId: "",
locationId: "",
isActive: true,
});
const [accounts, setAccounts] = useState([]);
const [locations, setLocations] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [loadingData, setLoadingData] = useState(false);
// Load accounts and locations for dropdowns
useEffect(() => {
const loadDropdownData = async () => {
try {
// Fetch accounts
const accountsRes = await get(`${API_URL}/Account/GetAccountList?pageSize=1000`);
const accountsData = accountsRes?.data || accountsRes?.Data || [];
setAccounts(accountsData);
// Fetch locations - using full API path
const locationsRes = await get(`/Common/GetSitesName`);
// Handle both array response and wrapped response
const locationsData = Array.isArray(locationsRes) ? locationsRes : (locationsRes?.data || locationsRes?.Data || []);
setLocations(locationsData);
} catch (err) {
console.error("Failed to load dropdown data:", err);
setError("Failed to load dropdown options");
}
};
loadDropdownData();
}, []);
// Load asset data if editing
useEffect(() => {
if (isEdit && id) {
const loadAsset = async () => {
setLoadingData(true);
try {
const asset = await get(`${API_URL}/Asset/GetAssetById?id=${id}`);
if (asset) {
// Handle both camelCase and PascalCase from backend
setFormData({
assetCode: asset.assetCode || asset.AssetCode || "",
name: asset.name || asset.Name || "",
description: asset.description || asset.Description || "",
accountId: asset.accountId || asset.AccountId || "",
locationId: asset.locationId || asset.LocationId || "",
isActive: asset.isActive ?? asset.IsActive ?? true,
});
}
} catch (err) {
console.error("Failed to load asset:", err);
setError("Failed to load asset data");
} finally {
setLoadingData(false);
}
};
loadAsset();
}
}, [isEdit, id]);
const handleChange = (e) => {
const { name, value, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: name === "isActive" ? checked : value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const endpoint = isEdit ? "EditAsset" : "AddAsset";
// Prepare payload with proper data types
const payload = {
assetCode: formData.assetCode,
name: formData.name,
description: formData.description || null,
accountId: formData.accountId ? parseInt(formData.accountId) : null,
locationId: formData.locationId ? parseInt(formData.locationId) : null,
isActive: formData.isActive,
};
// Add id for edit
if (isEdit) {
payload.id = parseInt(id);
}
await post(`${API_URL}/Asset/${endpoint}`, payload);
navigate("/assets");
} catch (err) {
console.error("Failed to save asset:", err);
setError(err.message || "Failed to save asset");
} finally {
setLoading(false);
}
};
if (loadingData) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate("/assets")}
sx={{ mb: 2 }}
>
Back to Assets
</Button>
<Paper sx={{ p: 3 }}>
<Typography variant="h4" component="h1" gutterBottom>
{isEdit ? "Edit Asset" : "Create New Asset"}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 3 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Asset Code"
name="assetCode"
value={formData.assetCode}
onChange={handleChange}
placeholder="Enter asset code"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Enter asset name"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={3}
label="Description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Enter asset description"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Account</InputLabel>
<Select
name="accountId"
value={formData.accountId}
onChange={handleChange}
label="Account"
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{accounts.map((account) => (
<MenuItem key={account.id} value={account.id}>
{account.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>Location</InputLabel>
<Select
name="locationId"
value={formData.locationId}
onChange={handleChange}
label="Location"
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{locations.map((location) => (
<MenuItem key={location.id} value={location.id}>
{location.title} - {location.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={handleChange}
name="isActive"
color="primary"
/>
}
label="Active"
/>
</Grid>
<Grid item xs={12}>
<Box sx={{ display: "flex", gap: 2, justifyContent: "flex-end" }}>
<Button
variant="outlined"
onClick={() => navigate("/assets")}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
color="primary"
startIcon={<SaveIcon />}
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update Asset" : "Create Asset"}
</Button>
</Box>
</Grid>
</Grid>
</Box>
</Paper>
</Box>
);
};
export default AssetFormPage;

View file

@ -0,0 +1,36 @@
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
} from "@mui/material";
const DeleteDialog = ({ open, onClose, onConfirm, itemName }) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the asset <strong>{itemName}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="inherit">
Cancel
</Button>
<Button onClick={onConfirm} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
);
};
export default DeleteDialog;

View file

@ -0,0 +1,133 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Button,
TextField,
Typography,
CircularProgress,
Alert,
Pagination,
InputAdornment,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import SearchIcon from "@mui/icons-material/Search";
import AssetsTable from "./Table";
import DeleteDialog from "./Delete";
import { useItems } from "./useItems";
import { deleteAsset } from "./api";
const AssetsList = () => {
const navigate = useNavigate();
const {
items,
loading,
error,
page,
totalPages,
search,
handleSearch,
handlePageChange,
reload,
} = useItems();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [searchInput, setSearchInput] = useState(search);
const handleDeleteClick = (id) => {
const item = items.find((i) => i.id === id);
setItemToDelete(item);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!itemToDelete) return;
try {
await deleteAsset(itemToDelete.id);
setDeleteDialogOpen(false);
setItemToDelete(null);
reload();
} catch (err) {
console.error("Failed to delete asset:", err);
alert("Failed to delete asset. Please try again.");
}
};
const handleSearchSubmit = (e) => {
e.preventDefault();
handleSearch(searchInput);
};
return (
<Box sx={{ p: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Typography variant="h4" component="h1">
Assets
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => navigate("/assets/new")}
>
New Asset
</Button>
</Box>
<Box component="form" onSubmit={handleSearchSubmit} sx={{ mb: 3 }}>
<TextField
fullWidth
placeholder="Search assets by name, code, or description..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : (
<>
<AssetsTable items={items} onDelete={handleDeleteClick} />
{totalPages > 1 && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={(e, value) => handlePageChange(value)}
color="primary"
/>
</Box>
)}
</>
)}
<DeleteDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
onConfirm={handleDeleteConfirm}
itemName={itemToDelete?.name || ""}
/>
</Box>
);
};
export default AssetsList;

View file

@ -0,0 +1,88 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Chip,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
const AssetsTable = ({ items, onDelete }) => {
const navigate = useNavigate();
const handleEdit = (id) => {
navigate(`/assets/edit/${id}`);
};
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell><strong>Asset Code</strong></TableCell>
<TableCell><strong>Name</strong></TableCell>
<TableCell><strong>Description</strong></TableCell>
<TableCell><strong>Account</strong></TableCell>
<TableCell><strong>Location</strong></TableCell>
<TableCell><strong>Status</strong></TableCell>
<TableCell align="right"><strong>Actions</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
No assets found
</TableCell>
</TableRow>
) : (
items.map((item) => (
<TableRow key={item.id} hover>
<TableCell>{item.assetCode}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.description || "-"}</TableCell>
<TableCell>{item.accountName || "-"}</TableCell>
<TableCell>{item.locationName || "-"}</TableCell>
<TableCell>
<Chip
label={item.isActive ? "Active" : "Inactive"}
color={item.isActive ? "success" : "default"}
size="small"
/>
</TableCell>
<TableCell align="right">
<IconButton
color="primary"
size="small"
onClick={() => handleEdit(item.id)}
title="Edit"
>
<EditIcon />
</IconButton>
<IconButton
color="error"
size="small"
onClick={() => onDelete(item.id)}
title="Delete"
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
);
};
export default AssetsTable;

View file

@ -0,0 +1,28 @@
import { get, post } from "../../../apiUtil";
import { API_URL } from '../../../constants';
export const fetchAssetsPaged = async ({ search = "", page = 1, pageSize = 12 }) => {
const res = await get(`${API_URL}/Asset/GetAssetList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`);
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id || x.Id,
assetCode: x.assetCode || x.AssetCode || "",
name: x.name || x.Name || "",
description: x.description || x.Description || "",
isActive: x.isActive ?? x.IsActive ?? true,
accountName: x.accountName || x.AccountName || "",
locationName: x.locationName || x.LocationName || "",
}));
return {
rows,
page: res?.pageNumber || res?.PageNumber || 1,
pageSize: res?.pageSize || res?.PageSize || pageSize,
totalCount: res?.totalCount || res?.TotalCount || rows.length,
totalPages: res?.totalPages || res?.TotalPages || 1,
};
};
export const deleteAsset = (id) =>
get(`${API_URL}/Asset/DeleteAsset?id=${id}`);

View file

@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from "react";
import { fetchAssetsPaged } from "./api";
export const useItems = () => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
const loadItems = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchAssetsPaged({ search, page, pageSize });
setItems(data.rows);
setTotalCount(data.totalCount);
setTotalPages(data.totalPages);
} catch (err) {
setError(err.message || "Failed to load assets");
setItems([]);
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
useEffect(() => {
loadItems();
}, [loadItems]);
const handleSearch = (newSearch) => {
setSearch(newSearch);
setPage(1);
};
const handlePageChange = (newPage) => {
setPage(newPage);
};
const handlePageSizeChange = (newPageSize) => {
setPageSize(newPageSize);
setPage(1);
};
return {
items,
loading,
error,
page,
pageSize,
totalCount,
totalPages,
search,
handleSearch,
handlePageChange,
handlePageSizeChange,
reload: loadItems,
};
};

View file

@ -0,0 +1,190 @@
/* Login Page Styles - Matching Legacy Officetrax Design */
.loginPage {
min-height: 100vh;
/* background-image set via inline style in JSX */
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-color: #333;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0;
margin: 0;
}
.login-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
margin-top: 7%;
padding: 0 15px;
}
.login-container {
width: 100%;
max-width: 400px;
}
.login-panel {
background-color: white;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
border-radius: 0;
overflow: hidden;
}
.login-branding {
height: 140px;
background-color: #FFFFFF;
margin: 0;
border-bottom: solid 1px #b8b5b5;
display: flex;
align-items: center;
justify-content: center;
/* background-image set via inline style in JSX */
background-repeat: no-repeat;
background-position: center;
background-size: auto 80%;
padding: 20px;
}
.login-header {
background-color: #003A5D;
border-bottom: solid 1px #b8b5b5;
height: 40px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.login-header h5 {
margin: 0;
padding: 0;
color: white;
font-size: 14px;
font-weight: bold;
line-height: 1;
text-align: center;
}
.login-body {
background-color: white;
padding: 30px 30px 20px;
}
.login-form .form-group {
margin-bottom: 30px;
}
.login-form .form-group:last-of-type {
margin-bottom: 0;
}
.login-form .form-control {
width: 100%;
height: 40px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 12px;
box-sizing: border-box;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.login-form .form-control:focus {
border-color: #66afe9;
outline: 0;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102,175,233,.6);
}
.login-form .form-control::placeholder {
color: #999;
}
.button-content {
margin-top: 30px;
text-align: center;
}
.login-button {
background: #003A5D;
color: white;
width: 250px;
height: 40px;
font-size: 14px;
font-weight: normal;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
display: inline-block;
}
.login-button:hover {
background: #00527d;
}
.login-button:disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.65;
}
.forgot-password {
text-align: center;
margin-top: 15px;
margin-bottom: 0;
}
.forgot-password a {
color: black;
font-weight: bold;
text-decoration: none;
font-size: 13px;
}
.forgot-password a:hover {
text-decoration: underline;
}
.error-message {
color: #dc3545;
font-size: 13px;
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.login-footer {
text-align: center;
margin-top: 20px;
padding: 0 15px;
}
.login-footer a {
color: white;
text-decoration: none;
margin: 0 3px;
font-size: 13px;
}
.login-footer a:hover {
text-decoration: underline;
}
.copyright {
text-align: center;
color: white;
font-size: 12px;
margin-top: 15px;
padding-bottom: 20px;
}
.copyright em {
font-style: italic;
}

143
src/pages/auth/LoginPage.js Normal file
View file

@ -0,0 +1,143 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../../pages/user/api";
import { setUser } from "../../tokenUtility";
import "./LoginPage.css";
export default function LoginPage() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
setError(""); // Clear error when user types
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const response = await login(formData);
// Store authentication data using tokenUtility
setUser(response);
// Redirect to dashboard/home
navigate("/");
} catch (err) {
console.error("Login error:", err);
setError("You have entered an invalid username or password");
} finally {
setLoading(false);
}
};
return (
<div
className="loginPage"
style={{
backgroundImage: `url(${process.env.PUBLIC_URL}/BrandingImage.png)`
}}
>
<div className="login-wrapper">
<div className="login-container">
<div className="login-panel">
{/* Logo Area */}
<div
className="login-branding"
style={{
backgroundImage: `url(${process.env.PUBLIC_URL}/logo.png)`
}}
></div>
{/* Header */}
<div className="login-header">
<h5>User Login</h5>
</div>
{/* Login Form */}
<div className="login-body">
<form onSubmit={handleSubmit} className="login-form" autoComplete="off">
<div className="form-group">
<input
type="email"
className="form-control"
id="username"
name="username"
placeholder="Username"
value={formData.username}
onChange={handleChange}
required
autoComplete="off"
/>
</div>
<div className="form-group">
<input
type="password"
className="form-control"
id="password"
name="password"
placeholder="Password"
value={formData.password}
onChange={handleChange}
required
autoComplete="new-password"
/>
</div>
{error && <div className="error-message">{error}</div>}
<div className="button-content">
<button
type="submit"
className="login-button"
disabled={loading}
>
{loading ? "Logging in..." : "Log in"}
</button>
</div>
</form>
<p className="forgot-password">
<a href="/forgot-password">Forgot your password?</a>
</p>
</div>
</div>
</div>
{/* Footer Links */}
<p className="login-footer">
<a href="https://www.officetrax.com" target="_blank" rel="noopener noreferrer">
www.officetrax.com
</a>
{" · "}
<a href="https://www.officetrax.com/Legal/TermsOfService" target="_blank" rel="noopener noreferrer">
Terms of Service
</a>
{" · "}
<a href="https://www.officetrax.com/Legal/PrivacyPolicy" target="_blank" rel="noopener noreferrer">
Privacy Policy
</a>
</p>
</div>
{/* Copyright */}
<p className="copyright">
©2026 <em>Core 7 Technologies Inc.</em> All rights reserved.
</p>
</div>
);
}

114
src/pages/calendar/api.js Normal file
View file

@ -0,0 +1,114 @@
import { get, post } from "../../apiUtil";
import { API_URL } from "../../constants";
// Fetch events for calendar view (with date range)
export async function fetchEventsForCalendar(start, end) {
const params = new URLSearchParams();
if (start) params.append("start", start.toISOString());
if (end) params.append("end", end.toISOString());
const response = await get(`${API_URL}/Calendar/GetEventsForCalendar?${params.toString()}`);
return response?.data || response?.Data || [];
}
// Fetch events with pagination
export async function fetchEventsPaged({ search = "", page = 1, pageSize = 12 }) {
const params = new URLSearchParams();
if (search) params.append("search", search);
params.append("page", page.toString());
params.append("pageSize", pageSize.toString());
const response = await get(`${API_URL}/Calendar/GetEventList?${params.toString()}`);
const data = response?.data || response?.Data || {};
return {
rows: data.rows || [],
totalCount: data.totalCount || 0,
};
}
// Get single event by ID
export async function getEventById(id) {
const response = await get(`${API_URL}/Calendar/GetEventById/${id}`);
const event = response?.data || response?.Data;
// Handle both camelCase and PascalCase
if (event) {
return {
id: event.id || event.Id,
title: event.title || event.Title || "",
description: event.description || event.Description || "",
location: event.location || event.Location || "",
recordID: event.recordID || event.RecordID || null,
recordName: event.recordName || event.RecordName || "",
recordTargetType: event.recordTargetType || event.RecordTargetType || "",
reminderMinutes: event.reminderMinutes || event.ReminderMinutes || 15,
eventColor: event.eventColor || event.EventColor || "primary",
startDate: event.startDate || event.StartDate || "",
startTime: event.startTime || event.StartTime || "",
endDate: event.endDate || event.EndDate || "",
endTime: event.endTime || event.EndTime || "",
allDay: event.allDay || event.AllDay || false,
};
}
return null;
}
// Create new event
export async function addEvent(eventData) {
const response = await post(`${API_URL}/Calendar/AddEvent`, {
Title: eventData.title,
Description: eventData.description,
Location: eventData.location,
RecordID: eventData.recordID,
RecordName: eventData.recordName,
RecordTargetType: eventData.recordTargetType,
ReminderMinutes: eventData.reminderMinutes,
EventColor: eventData.eventColor,
StartDate: eventData.startDate,
StartTime: eventData.startTime,
EndDate: eventData.endDate,
EndTime: eventData.endTime,
AllDay: eventData.allDay,
});
return response;
}
// Update existing event
export async function editEvent(eventData) {
const response = await post(`${API_URL}/Calendar/EditEvent`, {
Id: eventData.id,
Title: eventData.title,
Description: eventData.description,
Location: eventData.location,
RecordID: eventData.recordID,
RecordName: eventData.recordName,
RecordTargetType: eventData.recordTargetType,
ReminderMinutes: eventData.reminderMinutes,
EventColor: eventData.eventColor,
StartDate: eventData.startDate,
StartTime: eventData.startTime,
EndDate: eventData.endDate,
EndTime: eventData.endTime,
AllDay: eventData.allDay,
});
return response;
}
// Delete event
export async function deleteEvent(id) {
const response = await fetch(`${API_URL}/Calendar/DeleteEvent/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
throw new Error("Failed to delete event");
}
return response.json();
}

View file

@ -0,0 +1,142 @@
.calendarActionBar {
background: #f5f5f5;
border-bottom: 1px solid #ddd;
padding: 0;
display: flex;
justify-content: space-between;
align-items: stretch;
flex-wrap: wrap;
margin-bottom: 0;
}
.calActionBarNav {
list-style: none;
margin: 0;
padding: 0;
display: flex;
align-items: stretch;
}
.calActionBarNavRight {
margin-left: auto;
}
.calActionBarNav li {
position: relative;
display: flex;
align-items: center;
}
.calActionBarLink {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 15px;
color: #333;
text-decoration: none;
font-size: 14px;
transition: background-color 0.2s;
white-space: nowrap;
border: none;
background: transparent;
}
.calActionBarLink:hover {
background-color: #e8e8e8;
color: #333;
}
.calActionBarLink i {
font-size: 13px;
color: #666;
}
.calActionBarText {
font-weight: 400;
}
/* Dropdown Styles */
.calActionBarDropdown {
position: relative;
}
.calActionBarDropdown.open .calActionBarLink {
background-color: #e0e0e0;
}
.calActionBarDropdownMenu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 180px;
background: #fff;
border: 1px solid #ddd;
border-radius: 0 0 4px 4px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
list-style: none;
margin: 0;
padding: 5px 0;
}
.calActionBarDropdownMenu li {
border: none;
display: block;
}
.calActionBarDropdownMenu li a {
display: block;
padding: 8px 16px;
color: #333;
text-decoration: none;
font-size: 14px;
transition: background-color 0.2s;
}
.calActionBarDropdownMenu li a:hover {
background-color: #f5f5f5;
color: #333;
}
.calActionBarDropdownMenu li.divider {
height: 1px;
background-color: #e5e5e5;
margin: 5px 0;
padding: 0;
}
/* Responsive */
@media (max-width: 768px) {
.calendarActionBar {
flex-direction: column;
}
.calActionBarNav {
flex-wrap: wrap;
}
.calActionBarNavRight {
margin-left: 0;
}
.calActionBarLink {
padding: 10px 12px;
font-size: 13px;
}
.calActionBarText {
display: none;
}
.calActionBarLink i {
margin-right: 0;
}
}
@media (min-width: 769px) {
/* Show text on larger screens */
.calActionBarText {
display: inline;
}
}

View file

@ -0,0 +1,185 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import "./CalendarActionBar.css";
export default function CalendarActionBar({
onWorkspaceChange,
onEventTypeChange,
onTodayClick,
onPrevClick,
onNextClick,
onViewChange,
}) {
const navigate = useNavigate();
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const [eventTypeOpen, setEventTypeOpen] = useState(false);
const [selectedWorkspace, setSelectedWorkspace] = useState("All");
const [selectedEventType, setSelectedEventType] = useState("Events");
const toggleWorkspace = (e) => {
e.preventDefault();
e.stopPropagation();
setWorkspaceOpen(!workspaceOpen);
setEventTypeOpen(false);
};
const toggleEventType = (e) => {
e.preventDefault();
e.stopPropagation();
setEventTypeOpen(!eventTypeOpen);
setWorkspaceOpen(false);
};
const handleWorkspaceSelect = (workspace, type) => {
setSelectedWorkspace(workspace);
setWorkspaceOpen(false);
onWorkspaceChange && onWorkspaceChange(type);
};
const handleEventTypeSelect = (eventType, filter) => {
setSelectedEventType(eventType);
setEventTypeOpen(false);
onEventTypeChange && onEventTypeChange(filter);
};
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = () => {
setWorkspaceOpen(false);
setEventTypeOpen(false);
};
if (workspaceOpen || eventTypeOpen) {
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [workspaceOpen, eventTypeOpen]);
return (
<div className="calendarActionBar">
<ul className="calActionBarNav">
{/* Workspace Selector */}
<li className={`calActionBarDropdown ${workspaceOpen ? "open" : ""}`}>
<a
href="#"
className="calActionBarLink"
onClick={toggleWorkspace}
title="Change your workspace"
>
<i className="fa fa-inbox"></i>
<span className="calActionBarText">{selectedWorkspace}</span>
<i className="fa fa-angle-down"></i>
</a>
{workspaceOpen && (
<ul className="calActionBarDropdownMenu">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("All", "All"); }}>
All
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("Everyone else", "EveryoneElse"); }}>
Everyone else
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("Only Me", "OnlyMe"); }}>
Only Me
</a>
</li>
<li className="divider"></li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleWorkspaceSelect("Custom", "Custom"); }}>
Specific people...
</a>
</li>
</ul>
)}
</li>
{/* Event Type Filter */}
<li className={`calActionBarDropdown ${eventTypeOpen ? "open" : ""}`}>
<a
href="#"
className="calActionBarLink"
onClick={toggleEventType}
title="Filter events"
>
<i className="fa fa-filter"></i>
<span className="calActionBarText">{selectedEventType}</span>
<i className="fa fa-angle-down"></i>
</a>
{eventTypeOpen && (
<ul className="calActionBarDropdownMenu">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleEventTypeSelect("Events", "EventsOnly"); }}>
Events
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleEventTypeSelect("Follow Ups", "FollowUpsOnly"); }}>
Follow Ups
</a>
</li>
<li>
<a href="#" onClick={(e) => { e.preventDefault(); handleEventTypeSelect("Work Orders", "WorkOrdersOnly"); }}>
Work Orders
</a>
</li>
</ul>
)}
</li>
{/* New Event Button */}
<li>
<a
href="#"
className="calActionBarLink"
onClick={(e) => {
e.preventDefault();
navigate("/calendar/new");
}}
>
<i className="fa fa-plus"></i>
<span className="calActionBarText">New Event</span>
</a>
</li>
</ul>
{/* Right Side Navigation */}
<ul className="calActionBarNav calActionBarNavRight">
<li>
<a href="#" className="calActionBarLink" onClick={(e) => { e.preventDefault(); onTodayClick && onTodayClick(); }}>
Today
</a>
</li>
<li>
<a href="#" className="calActionBarLink" onClick={(e) => { e.preventDefault(); onPrevClick && onPrevClick(); }}>
<i className="fa fa-angle-double-left"></i>
</a>
</li>
<li>
<a href="#" className="calActionBarLink" onClick={(e) => { e.preventDefault(); onNextClick && onNextClick(); }}>
<i className="fa fa-angle-double-right"></i>
</a>
</li>
<li>
<a href="#" className="calActionBarLink" onClick={(e) => { e.preventDefault(); onViewChange && onViewChange("dayGridMonth"); }}>
Month
</a>
</li>
<li>
<a href="#" className="calActionBarLink" onClick={(e) => { e.preventDefault(); onViewChange && onViewChange("timeGridWeek"); }}>
Week
</a>
</li>
<li>
<a href="#" className="calActionBarLink" onClick={(e) => { e.preventDefault(); onViewChange && onViewChange("timeGridDay"); }}>
Day
</a>
</li>
</ul>
</div>
);
}

View file

@ -0,0 +1,344 @@
.eventFormPage {
padding: 0;
background-color: #f5f5f5;
min-height: 100vh;
}
.eventFormPage .container {
max-width: 100%;
margin: 0;
padding: 0;
}
.eventFormPage .panel {
background: white;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
border: 1px solid #ddd;
}
.eventFormPage .panel-body {
padding: 20px;
}
.eventFormPage .breadcrumb {
background: transparent;
padding: 0;
margin-bottom: 20px;
}
.eventFormPage .breadcrumb li {
display: inline;
font-size: 14px;
}
.eventFormPage .breadcrumb li + li:before {
content: "/ ";
padding: 0 8px;
color: #ccc;
}
.eventFormPage .breadcrumb a {
color: #337ab7;
text-decoration: none;
}
.eventFormPage .breadcrumb .active {
color: #666;
}
.eventFormPage .heading {
margin: 0 0 20px 0;
color: #333;
}
.eventFormPage .form-group {
margin-bottom: 20px;
position: relative;
}
.eventFormPage .form-control {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
height: auto;
min-height: 42px;
}
.eventFormPage select.form-control {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
.eventFormPage .form-control:focus {
outline: none;
border-color: #66afe9;
box-shadow: 0 0 5px rgba(102, 175, 233, 0.5);
}
.eventFormPage textarea.form-control {
resize: vertical;
min-height: 80px;
}
.eventFormPage label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
font-size: 14px;
}
.eventFormPage .required-field::after {
content: " *";
color: #d9534f;
}
.eventFormPage .input-counter {
position: absolute;
right: 10px;
top: 0;
font-size: 12px;
color: #999;
}
.eventFormPage .row {
display: flex;
margin: 0 -10px;
flex-wrap: wrap;
align-items: flex-start;
}
.eventFormPage .col-sm-5,
.eventFormPage .col-sm-6,
.eventFormPage .col-sm-2,
.eventFormPage .col-md-4,
.eventFormPage .col-md-6 {
padding: 0 10px;
margin-bottom: 15px;
}
.eventFormPage .col-sm-5 {
flex: 0 0 40%;
max-width: 40%;
}
.eventFormPage .col-sm-6 {
flex: 0 0 50%;
max-width: 50%;
}
.eventFormPage .col-sm-2 {
flex: 0 0 10%;
max-width: 10%;
display: flex;
align-items: center;
justify-content: center;
padding-top: 10px;
}
.eventFormPage .col-sm-2 h5 {
margin: 0;
font-size: 14px;
font-weight: normal;
color: #666;
}
.eventFormPage .col-md-6 {
flex: 0 0 50%;
max-width: 50%;
}
.eventFormPage hr {
border: 0;
border-top: 1px solid #eee;
margin: 30px 0;
}
.eventFormPage .btn {
padding: 10px 24px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
}
.eventFormPage .btn-success {
background-color: #5cb85c;
color: white;
}
.eventFormPage .btn-success:hover:not(:disabled) {
background-color: #449d44;
}
.eventFormPage .btn-default {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ccc;
}
.eventFormPage .btn-default:hover:not(:disabled) {
background-color: #e6e6e6;
}
.eventFormPage .btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.eventFormPage .text-right {
text-align: right;
}
.eventFormPage .text-uppercase {
text-transform: uppercase;
}
.eventFormPage .text-pad-right {
margin-right: 15px;
}
.eventFormPage .text-sm {
font-size: 12px;
color: #666;
}
/* Color Picker Styles */
.eventFormPage .theme-picker {
margin-top: 10px;
}
.eventFormPage .theme-colors {
list-style: none;
padding: 0;
margin: 0;
display: flex;
gap: 10px;
}
.eventFormPage .theme-colors li {
margin: 0;
}
.eventFormPage .theme-colors a {
display: block;
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid transparent;
transition: all 0.2s;
cursor: pointer;
}
.eventFormPage .theme-colors li.active a {
border-color: #333;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
}
.eventFormPage .theme-colors a.primary {
background-color: #337ab7;
}
.eventFormPage .theme-colors a.success {
background-color: #5cb85c;
}
.eventFormPage .theme-colors a.info {
background-color: #5bc0de;
}
.eventFormPage .theme-colors a.warning {
background-color: #f0ad4e;
}
.eventFormPage .theme-colors a.danger {
background-color: #d9534f;
}
/* Checkbox Styles */
.eventFormPage .checkbox.stylized {
margin-top: 15px;
}
.eventFormPage .checkbox.stylized label {
display: flex;
align-items: center;
cursor: pointer;
font-weight: normal;
}
.eventFormPage .checkbox.stylized input[type="checkbox"] {
margin-right: 8px;
width: 18px;
height: 18px;
cursor: pointer;
}
/* Panel Placeholder for Related Record */
.eventFormPage .panel-placeholder {
border: 2px dashed #ddd;
border-radius: 4px;
padding: 20px;
background-color: #fafafa;
cursor: pointer;
transition: all 0.2s;
}
.eventFormPage .panel-placeholder:hover {
border-color: #337ab7;
background-color: #f0f7ff;
}
.eventFormPage .panel-placeholder .panel-body {
padding: 0;
}
.eventFormPage .panel-placeholder p {
margin: 0;
}
.eventFormPage .text-muted {
color: #999;
}
.eventFormPage .text-primary {
color: #337ab7;
}
/* Responsive */
@media (max-width: 768px) {
.eventFormPage .col-sm-5,
.eventFormPage .col-sm-6,
.eventFormPage .col-sm-2,
.eventFormPage .col-md-4,
.eventFormPage .col-md-6 {
flex: 0 0 100%;
max-width: 100%;
}
.eventFormPage .col-sm-2 {
justify-content: flex-start;
padding-top: 0;
}
.eventFormPage .col-sm-2 h5 {
margin: 10px 0;
}
.eventFormPage .panel-body {
padding: 15px;
}
}

View file

@ -0,0 +1,450 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { getEventById, addEvent, editEvent } from "../api";
import "./EventFormPage.css";
export default function EventFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [formData, setFormData] = useState({
title: "",
description: "",
location: "",
recordID: null,
recordName: "",
recordTargetType: "",
reminderMinutes: 15,
eventColor: "primary",
startDate: new Date().toISOString().split("T")[0],
startTime: "08:00 AM",
endDate: new Date().toISOString().split("T")[0],
endTime: "08:30 AM",
allDay: false,
});
const [loading, setLoading] = useState(false);
const [titleCounter, setTitleCounter] = useState(100);
const [locationCounter, setLocationCounter] = useState(150);
// Time options for dropdowns (30-minute intervals)
const timeOptions = [
"12:00 AM", "12:30 AM", "01:00 AM", "01:30 AM", "02:00 AM", "02:30 AM",
"03:00 AM", "03:30 AM", "04:00 AM", "04:30 AM", "05:00 AM", "05:30 AM",
"06:00 AM", "06:30 AM", "07:00 AM", "07:30 AM", "08:00 AM", "08:30 AM",
"09:00 AM", "09:30 AM", "10:00 AM", "10:30 AM", "11:00 AM", "11:30 AM",
"12:00 PM", "12:30 PM", "01:00 PM", "01:30 PM", "02:00 PM", "02:30 PM",
"03:00 PM", "03:30 PM", "04:00 PM", "04:30 PM", "05:00 PM", "05:30 PM",
"06:00 PM", "06:30 PM", "07:00 PM", "07:30 PM", "08:00 PM", "08:30 PM",
"09:00 PM", "09:30 PM", "10:00 PM", "10:30 PM", "11:00 PM", "11:30 PM",
];
// Reminder options
const reminderOptions = [
{ value: 0, label: "Never" },
{ value: 5, label: "5 minutes before" },
{ value: 10, label: "10 minutes before" },
{ value: 15, label: "15 minutes before" },
{ value: 30, label: "30 minutes before" },
{ value: 60, label: "1 hour before" },
{ value: 120, label: "2 hours before" },
{ value: 240, label: "4 hours before" },
{ value: 360, label: "6 hours before" },
{ value: 720, label: "12 hours before" },
{ value: 1440, label: "1 day before" },
];
// Load event data if editing
useEffect(() => {
if (isEdit && id) {
const loadEvent = async () => {
try {
const event = await getEventById(id);
if (event) {
setFormData(event);
setTitleCounter(100 - (event.title?.length || 0));
setLocationCounter(150 - (event.location?.length || 0));
}
} catch (err) {
console.error("Failed to load event", err);
toast.error("Failed to load event");
}
};
loadEvent();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
const newValue = type === "checkbox" ? checked : value;
setFormData((prev) => ({
...prev,
[name]: newValue,
}));
// Update character counters
if (name === "title") {
setTitleCounter(100 - value.length);
} else if (name === "location") {
setLocationCounter(150 - value.length);
}
};
const handleColorSelect = (color) => {
setFormData((prev) => ({
...prev,
eventColor: color,
}));
};
const handleSave = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (isEdit) {
await editEvent({ ...formData, id: parseInt(id) });
toast.success("Event updated successfully!");
} else {
await addEvent(formData);
toast.success("Event created successfully!");
}
navigate("/calendar");
} catch (err) {
console.error(err);
toast.error("Failed to save event");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/calendar");
};
return (
<div className="eventFormPage">
<div className="container">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="/calendar">Calendar</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<form onSubmit={handleSave}>
<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 Event" : "Create an Event"}</span>
</h3>
</div>
</div>
{/* General Details */}
<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 col-md-6">
<div className="form-group">
<span className="input-counter">{titleCounter}</span>
<label className="required-field" htmlFor="title">
Title
</label>
<input
className="form-control"
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Enter Event title"
maxLength={100}
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="recordName">Related to...</label>
<div className="panel-placeholder text-center">
<div className="panel-body">
<p>
<strong>
{formData.recordName ? (
<span className="text-primary">{formData.recordName}</span>
) : (
<span className="text-muted">Click here to search records</span>
)}
</strong>
</p>
</div>
</div>
<input
type="hidden"
name="recordID"
value={formData.recordID || ""}
/>
</div>
</div>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
className="form-control"
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Enter details about the Event"
rows={3}
/>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<span className="input-counter">{locationCounter}</span>
<label htmlFor="location">Location</label>
<input
className="form-control"
type="text"
id="location"
name="location"
value={formData.location}
onChange={handleChange}
placeholder="Enter location"
maxLength={150}
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="reminderMinutes">Remind Attendees</label>
<select
className="form-control"
id="reminderMinutes"
name="reminderMinutes"
value={formData.reminderMinutes}
onChange={handleChange}
required
>
{reminderOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
<div className="form-group">
<label htmlFor="eventColor">Select a Calendar Color</label>
<div className="theme-picker">
<ul className="theme-colors">
<li className={formData.eventColor === "primary" ? "active" : ""}>
<a
href="#"
className="primary"
onClick={(e) => {
e.preventDefault();
handleColorSelect("primary");
}}
></a>
</li>
<li className={formData.eventColor === "success" ? "active" : ""}>
<a
href="#"
className="success"
onClick={(e) => {
e.preventDefault();
handleColorSelect("success");
}}
></a>
</li>
<li className={formData.eventColor === "info" ? "active" : ""}>
<a
href="#"
className="info"
onClick={(e) => {
e.preventDefault();
handleColorSelect("info");
}}
></a>
</li>
<li className={formData.eventColor === "warning" ? "active" : ""}>
<a
href="#"
className="warning"
onClick={(e) => {
e.preventDefault();
handleColorSelect("warning");
}}
></a>
</li>
<li className={formData.eventColor === "danger" ? "active" : ""}>
<a
href="#"
className="danger"
onClick={(e) => {
e.preventDefault();
handleColorSelect("danger");
}}
></a>
</li>
</ul>
</div>
</div>
</div>
<hr />
{/* Schedule */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Schedule</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-5" style={{ display: formData.allDay ? "none" : "block" }}>
<div className="form-group">
<input
className="form-control"
type="date"
id="startDate"
name="startDate"
value={formData.startDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-5" style={{ display: formData.allDay ? "none" : "block" }}>
<div className="form-group">
<select
className="form-control"
id="startTime"
name="startTime"
value={formData.startTime}
onChange={handleChange}
required={!formData.allDay}
>
{timeOptions.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>
<div className="col-sm-2" style={{ display: formData.allDay ? "none" : "block" }}>
<h5>to</h5>
</div>
</div>
<div className="row">
<div className="col-sm-5" style={{ display: formData.allDay ? "none" : "block" }}>
<div className="form-group">
<input
className="form-control"
type="date"
id="endDate"
name="endDate"
value={formData.endDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-5" style={{ display: formData.allDay ? "none" : "block" }}>
<div className="form-group">
<select
className="form-control"
id="endTime"
name="endTime"
value={formData.endTime}
onChange={handleChange}
required={!formData.allDay}
>
{timeOptions.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>
</div>
<div className="checkbox stylized">
<label>
<input
className="check-box"
type="checkbox"
id="allDay"
name="allDay"
checked={formData.allDay}
onChange={handleChange}
/>
<span className="helper"></span>
<span>All day Event</span>
</label>
</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="button"
className="btn btn-lg btn-default"
onClick={handleCancel}
disabled={loading}
style={{ marginRight: "10px" }}
>
Cancel
</button>
<button
type="submit"
className="btn btn-lg btn-success"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,178 @@
.calendarPage {
padding: 20px;
background-color: #f5f5f5;
min-height: calc(100vh - 60px);
}
.calendarPage .breadcrumb {
font-size: 14px;
margin-bottom: 20px;
color: #666;
}
.calendarPage .breadcrumbItem {
color: #333;
font-weight: 500;
}
/* Calendar Container */
.calendarContainer {
background: white;
padding: 20px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
}
.calendarLoading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
text-align: center;
color: #999;
}
.calendarLoading i {
margin-bottom: 10px;
}
.calendarLoading p {
margin: 0;
font-size: 14px;
}
/* Calendar Title */
.calendarTitle {
text-align: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e5e5e5;
}
.calendarTitle h2 {
margin: 0;
font-size: 24px;
font-weight: 500;
color: #666;
}
/* FullCalendar Customization */
.calendarPage .fc {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Calendar cells */
.calendarPage .fc-daygrid-day {
border-color: #ddd;
}
.calendarPage .fc-scrollgrid {
border-color: #ddd;
}
.calendarPage .fc-theme-standard td,
.calendarPage .fc-theme-standard th {
border-color: #ddd;
}
.calendarPage .fc-toolbar-title {
font-size: 1.5rem;
font-weight: 600;
color: #333;
}
.calendarPage .fc-button {
background: #337ab7;
border-color: #337ab7;
text-transform: capitalize;
}
.calendarPage .fc-button:hover {
background: #286090;
border-color: #286090;
}
.calendarPage .fc-button-active {
background: #204d74 !important;
border-color: #204d74 !important;
}
.calendarPage .fc-button:focus {
box-shadow: 0 0 0 2px rgba(51, 122, 183, 0.2);
}
.calendarPage .fc-daygrid-event {
cursor: pointer;
margin: 1px 2px;
padding: 2px 4px;
border-radius: 3px;
}
.calendarPage .fc-daygrid-event:hover {
opacity: 0.85;
}
.calendarPage .fc-event-title {
font-weight: 500;
}
.calendarPage .fc-daygrid-day-number {
padding: 8px;
font-size: 14px;
color: #333;
}
.calendarPage .fc-col-header-cell-cushion {
padding: 10px;
font-weight: 600;
color: #666;
}
/* Today's date - Pinkish/Beige highlight like legacy */
.calendarPage .fc-daygrid-day.fc-day-today {
background-color: #fffbf0 !important;
}
.calendarPage .fc-daygrid-day.fc-day-today .fc-daygrid-day-number {
background-color: #fff8dc;
color: #333;
font-weight: 600;
}
/* Dates from other months - Gray background like legacy */
.calendarPage .fc-day-other {
background-color: #f0f0f0 !important;
}
.calendarPage .fc-day-other .fc-daygrid-day-number {
color: #bbb !important;
}
/* Hide events on dates from other months */
.calendarPage .fc-day-other .fc-daygrid-event-harness {
display: none !important;
}
/* Responsive Styles */
@media (max-width: 768px) {
.calendarPage {
padding: 10px;
}
.calendarContainer {
padding: 10px;
}
.calendarTitle h2 {
font-size: 18px;
}
.calendarPage .fc-daygrid-day-number {
font-size: 12px;
padding: 4px;
}
}

View file

@ -0,0 +1,250 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { fetchEventsForCalendar, deleteEvent } from "../api";
import DeleteModal from "../../../components/DeleteModal";
import CalendarActionBar from "../components/CalendarActionBar";
import "./CalendarPage.css";
export default function CalendarPage() {
const navigate = useNavigate();
const calendarRef = useRef(null);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [eventTypeFilter, setEventTypeFilter] = useState("Events");
const [workspaceFilter, setWorkspaceFilter] = useState("All");
const [currentTitle, setCurrentTitle] = useState("March 2026");
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
item: null,
loading: false,
});
// Load events for the current calendar view
const loadEvents = async (start, end) => {
setLoading(true);
try {
const data = await fetchEventsForCalendar(start, end);
// Convert to FullCalendar format
const formattedEvents = data.map((event) => {
// Extract date part only (YYYY-MM-DD) from datetime string
const startDateOnly = event.startDate?.split('T')[0] || event.startDate?.substring(0, 10);
const endDateOnly = event.endDate?.split('T')[0] || event.endDate?.substring(0, 10);
let startValue, endValue;
if (event.allDay) {
// For all-day events, FullCalendar uses exclusive end dates
// So if event is on April 5th, end should be April 6th
startValue = startDateOnly;
const endDate = new Date(endDateOnly);
endDate.setDate(endDate.getDate() + 1); // Add 1 day for exclusive end
endValue = endDate.toISOString().split('T')[0];
} else {
// For timed events, combine date and time
startValue = `${startDateOnly}T${convertTimeToISO(event.startTime)}`;
endValue = `${endDateOnly}T${convertTimeToISO(event.endTime)}`;
}
return {
id: event.id,
title: event.title,
start: startValue,
end: endValue,
allDay: event.allDay,
backgroundColor: getColorHex(event.eventColor),
borderColor: getColorHex(event.eventColor),
extendedProps: {
description: event.description,
location: event.location,
eventColor: event.eventColor,
rawEvent: event,
},
};
});
setEvents(formattedEvents);
} catch (err) {
console.error("Failed to load events", err);
toast.error("Failed to load events");
} finally {
setLoading(false);
}
};
// Convert "08:00 AM" to "08:00:00"
const convertTimeToISO = (timeStr) => {
if (!timeStr) return "00:00:00";
const [time, period] = timeStr.split(" ");
let [hours, minutes] = time.split(":");
hours = parseInt(hours);
if (period === "PM" && hours !== 12) {
hours += 12;
} else if (period === "AM" && hours === 12) {
hours = 0;
}
return `${hours.toString().padStart(2, "0")}:${minutes}:00`;
};
// Get color hex from color name
const getColorHex = (colorName) => {
const colors = {
primary: "#337ab7",
success: "#5cb85c",
info: "#5bc0de",
warning: "#f0ad4e",
danger: "#d9534f",
};
return colors[colorName] || colors.primary;
};
// Initial load
useEffect(() => {
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) {
const start = calendarApi.view.activeStart;
const end = calendarApi.view.activeEnd;
loadEvents(start, end);
}
}, [eventTypeFilter, workspaceFilter]);
// Handle date range change (when user navigates to different month/week)
const handleDatesSet = (dateInfo) => {
loadEvents(dateInfo.start, dateInfo.end);
// Update title
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) {
setCurrentTitle(calendarApi.view.title);
}
};
// Handle event click
const handleEventClick = (clickInfo) => {
const eventId = clickInfo.event.id;
navigate(`/calendar/edit/${eventId}`);
};
// Handle date click (create new event)
const handleDateClick = (dateInfo) => {
// Navigate to create event with pre-filled date
navigate(`/calendar/new?date=${dateInfo.dateStr}`);
};
// Handle delete
const handleDeleteConfirm = async () => {
setDeleteModal((prev) => ({ ...prev, loading: true }));
try {
await deleteEvent(deleteModal.item.id);
toast.success("Event deleted successfully!");
setDeleteModal({ isOpen: false, item: null, loading: false });
// Reload events
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) {
const start = calendarApi.view.activeStart;
const end = calendarApi.view.activeEnd;
loadEvents(start, end);
}
} catch (err) {
console.error(err);
toast.error("Failed to delete event");
setDeleteModal((prev) => ({ ...prev, loading: false }));
}
};
// Navigate to today
const handleToday = () => {
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) {
calendarApi.today();
}
};
// Change view
const handleChangeView = (viewType) => {
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) {
calendarApi.changeView(viewType);
}
};
return (
<>
<CalendarActionBar
onWorkspaceChange={(type) => setWorkspaceFilter(type)}
onEventTypeChange={(filter) => setEventTypeFilter(filter)}
onTodayClick={handleToday}
onPrevClick={() => {
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) calendarApi.prev();
}}
onNextClick={() => {
const calendarApi = calendarRef.current?.getApi();
if (calendarApi) calendarApi.next();
}}
onViewChange={handleChangeView}
/>
<section className="calendarPage">
{/* Calendar Container */}
<div className="calendarContainer">
{loading && (
<div className="calendarLoading">
<i className="fa fa-spinner fa-spin fa-2x"></i>
<p>Loading events...</p>
</div>
)}
{/* Custom Title Display */}
<div className="calendarTitle">
<h2>{currentTitle}</h2>
</div>
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={false}
events={events}
editable={false}
selectable={true}
selectMirror={true}
dayMaxEvents={true}
weekends={true}
datesSet={handleDatesSet}
eventClick={handleEventClick}
dateClick={handleDateClick}
height="auto"
fixedWeekCount={false}
showNonCurrentDates={true}
eventTimeFormat={{
hour: "2-digit",
minute: "2-digit",
meridiem: "short",
}}
/>
</div>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete Event"
message={`Are you sure you want to delete "${deleteModal.item?.title}"? This action cannot be undone.`}
loading={deleteModal.loading}
/>
</section>
</>
);
}

View file

@ -0,0 +1,163 @@
export default function Table({
loading,
error,
rows,
onEdit,
onDelete,
page,
setPage,
pageSize,
setPageSize,
totalCount,
totalPages,
}) {
if (loading) {
return (
<div className="tableContainer">
<div className="loadingState">
<i className="fa fa-spinner fa-spin fa-3x"></i>
<p>Loading events...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="tableContainer">
<div className="errorState">
<i className="fa fa-exclamation-triangle fa-3x"></i>
<p>{error}</p>
</div>
</div>
);
}
if (rows.length === 0) {
return (
<div className="tableContainer">
<div className="emptyState">
<i className="fa fa-calendar fa-3x"></i>
<p>No events found</p>
<small>Create your first event to get started</small>
</div>
</div>
);
}
const formatDate = (dateString) => {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const getColorBadgeClass = (color) => {
return `colorBadge badge-${color || "primary"}`;
};
return (
<div className="tableContainer">
<table className="dataTable">
<thead>
<tr>
<th>Title</th>
<th>Start Date</th>
<th>End Date</th>
<th>Location</th>
<th>Color</th>
<th>All Day</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<strong>{row.title}</strong>
{row.description && (
<div className="description">{row.description.substring(0, 100)}</div>
)}
</td>
<td>
{formatDate(row.startDate)}
{row.startTime && <div className="timeText">{row.startTime}</div>}
</td>
<td>
{formatDate(row.endDate)}
{row.endTime && <div className="timeText">{row.endTime}</div>}
</td>
<td>{row.location || "-"}</td>
<td>
<span className={getColorBadgeClass(row.eventColor)}>
{row.eventColor || "primary"}
</span>
</td>
<td>{row.allDay ? "Yes" : "No"}</td>
<td>
<div className="actionButtons">
<button
className="actionBtn editBtn"
onClick={() => onEdit(row)}
title="Edit"
>
<i className="fa fa-edit"></i>
</button>
<button
className="actionBtn deleteBtn"
onClick={() => onDelete(row)}
title="Delete"
>
<i className="fa fa-trash"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="pagination">
<div className="paginationInfo">
Showing {(page - 1) * pageSize + 1} to {Math.min(page * pageSize, totalCount)} of{" "}
{totalCount} events
</div>
<div className="paginationControls">
<label htmlFor="pageSize">Rows per page:</label>
<select
id="pageSize"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setPage(1);
}}
>
<option value={12}>12</option>
<option value={24}>24</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<button className="pageBtn" disabled={page === 1} onClick={() => setPage(page - 1)}>
Previous
</button>
<span className="pageInfo">
Page {page} of {totalPages}
</span>
<button
className="pageBtn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { fetchEventsPaged } from "../api";
const useEvents = () => {
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(true);
const [err, setErr] = useState("");
const reload = useCallback(() => {
setLoading(true);
setErr("");
fetchEventsPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
useEffect(() => {
reload();
}, [reload]);
return useMemo(
() => ({
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload]
);
};
export default useEvents;

View file

@ -1,94 +1,131 @@
// 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",
},
];
import { employeeApi } from "../../services/api";
export const getEmployees = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockEmployees];
// Transform backend data to frontend format
const transformEmployee = (employee) => {
if (!employee) return null;
return {
id: employee.id,
name: `${employee.firstName || ""} ${employee.lastName || ""}`.trim(),
email: employee.email || "",
phone: employee.phone || "",
jobTitle: employee.jobTitle || "",
role: employee.role || "",
status: employee.status || "",
hireDate: employee.hireDate || "",
address: employee.address || "",
department: employee.department || "",
// Include all other fields for form editing
firstName: employee.firstName || "",
middleName: employee.middleName || "",
lastName: employee.lastName || "",
dateOfBirth: employee.dateOfBirth || "",
street: employee.street || "",
streetLineTwo: employee.streetLineTwo || "",
city: employee.city || "",
state: employee.state || "",
zipCode: employee.zipCode || "",
country: employee.country || "US",
county: employee.county || "",
timeZone: employee.timeZone || "Eastern Standard Time",
gender: employee.gender || "",
employeeNumber: employee.employeeNumber || "",
regionName: employee.regionName || "",
userName: employee.userName || "",
smsAddress: employee.smsAddress || "",
website: employee.website || "",
facebookUrl: employee.facebookUrl || "",
linkedInUrl: employee.linkedInUrl || "",
twitterUrl: employee.twitterUrl || "",
};
};
// Transform frontend data to backend format
const transformToBackend = (employee) => {
return {
id: employee.id,
firstName: employee.firstName || "",
middleName: employee.middleName || null,
lastName: employee.lastName || "",
email: employee.email || "",
phone: employee.phone || "",
jobTitle: employee.jobTitle || "",
role: employee.role || "Technician",
status: employee.status || "Active",
hireDate: employee.hireDate || null,
dateOfBirth: employee.dateOfBirth || null,
address: employee.address || "",
street: employee.street || "",
streetLineTwo: employee.streetLineTwo || null,
city: employee.city || "",
state: employee.state || "",
zipCode: employee.zipCode || "",
country: employee.country || "US",
county: employee.county || null,
department: employee.department || "Field Service",
timeZone: employee.timeZone || "Eastern Standard Time",
gender: employee.gender || null,
employeeNumber: employee.employeeNumber || null,
regionName: employee.regionName || null,
userName: employee.userName || employee.email || "",
smsAddress: employee.smsAddress || null,
website: employee.website || null,
facebookUrl: employee.facebookUrl || null,
linkedInUrl: employee.linkedInUrl || null,
twitterUrl: employee.twitterUrl || null,
};
};
export const getEmployees = async (params = {}) => {
try {
const response = await employeeApi.getAll(params);
// Handle both paginated and non-paginated responses
const employees = response.data || response;
return Array.isArray(employees) ? employees.map(transformEmployee) : [];
} catch (error) {
console.error("Error fetching employees:", error);
throw error;
}
};
export const getEmployeeById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockEmployees.find(e => e.id === parseInt(id));
try {
const employee = await employeeApi.getById(id);
return transformEmployee(employee);
} catch (error) {
console.error("Error fetching employee:", error);
throw error;
}
};
export const addEmployee = async (employee) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newEmployee = { id: mockEmployees.length + 1, ...employee };
mockEmployees.push(newEmployee);
return newEmployee;
try {
const backendData = transformToBackend(employee);
const created = await employeeApi.create(backendData);
return transformEmployee(created);
} catch (error) {
console.error("Error creating employee:", error);
throw error;
}
};
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;
try {
const backendData = transformToBackend(employee);
const updated = await employeeApi.update(employee.id, backendData);
return transformEmployee(updated);
} catch (error) {
console.error("Error updating employee:", error);
throw error;
}
};
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;
try {
await employeeApi.delete(id);
return true;
} catch (error) {
console.error("Error deleting employee:", error);
throw error;
}
};

View file

@ -1,33 +1,23 @@
import { get } from "../../../apiUtil";
import { API_URL } from "../../../constants";
export const fetchEmployeesPaged = async ({
search = "",
page = 1,
pageSize = 12,
}) => {
const res = await get(
`${API_URL}/Employee/GetEmployeeList?search=${encodeURIComponent(
search
)}&page=${page}&pageSize=${pageSize}`
);
const rows = (res || []).map((x) => ({
export const fetchEmployeesPaged = async ({ search = "", page = 1, pageSize = 12 }) => {
const res = await get(`${API_URL}/Employee/GetEmployeeList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`);
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id,
name: `${x.name ?? ""} ${x.name ?? ""}`.trim(),
name: `${x.firstName ?? ""} ${x.lastName ?? ""}`.trim(),
jobTitle: x.jobTitle ?? "",
email: x.email ?? "",
phone: x.phone ?? "",
department: x.department ?? "",
status: x.status ?? "",
}));
return {
rows,
page: res?.PageNumber ?? 1,
pageSize: res?.PageSize ?? pageSize,
totalCount: res?.TotalCount ?? rows.length,
totalPages: res?.TotalPages ?? 1,
page: res?.pageNumber ?? 1,
pageSize: res?.pageSize ?? pageSize,
totalCount: res?.totalCount ?? rows.length,
totalPages: res?.totalPages ?? 1,
};
};

View file

@ -1,42 +1,31 @@
import { useState, useEffect } from "react";
import { getEmployees } from "../api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchEmployeesPaged } from "./api";
const useEmployees = () => {
export default function 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 [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const reload = async () => {
const reload = useCallback(() => {
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();
setErr("");
fetchEmployeesPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
useEffect(() => { reload(); }, [reload]);
export default useEmployees;
return useMemo(() => ({
rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err, reload
}), [rows, search, page, pageSize, totalCount, loading, err, reload]);
}

View file

@ -0,0 +1,289 @@
/* Follow-up Form Page - Matching Employee Form Style */
.followUpFormPage {
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-4 {
width: 33.33333333%;
}
.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;
}
/* 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;
}
textarea.form-control {
height: auto;
min-height: 100px;
}
/* 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-default {
color: #333;
background-color: #fff;
border-color: #ccc;
}
.btn-default:hover {
background-color: #e6e6e6;
border-color: #adadad;
}
.btn-primary {
color: #fff;
background-color: #0088cc;
border-color: #0088cc;
}
.btn-primary:hover {
background-color: #006699;
border-color: #005580;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
/* Responsive */
@media (max-width: 767px) {
[class*="col-sm-"] {
width: 100%;
}
}

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getFollowUpById, addFollowUp, editFollowUp } from "../api";
import "./FollowUpFormPage.css";
export default function FollowUpFormPage() {
const { id } = useParams();
@ -71,149 +72,202 @@ export default function FollowUpFormPage() {
};
return (
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit Follow-up" : "Create Follow-up"}
</div>
<div className="followUpFormPage">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); navigate("/followups"); }}>
Follow-ups
</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<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 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 a Follow-up" : "Create a Follow-up"}</span>
</h3>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>Scheduled Date *</label>
<input
type="date"
name="scheduledDate"
value={formData.scheduledDate}
onChange={handleChange}
required
/>
{/* Follow-up Details Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Follow-up Details</strong>
</h5>
</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 className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="workOrder">Work Order</label>
<select
className="form-control"
id="workOrder"
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>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="reason">Reason</label>
<input
className="form-control"
type="text"
id="reason"
name="reason"
value={formData.reason}
onChange={handleChange}
placeholder="Follow-up reason"
required
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="scheduledDate">Scheduled Date</label>
<input
className="form-control"
type="date"
id="scheduledDate"
name="scheduledDate"
value={formData.scheduledDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="status">Status</label>
<select
className="form-control"
id="status"
name="status"
value={formData.status}
onChange={handleChange}
required
>
<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>
</div>
</div>
{/* 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>
{/* Creator Information Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Creator Information</strong>
</h5>
</div>
<div className="formGroup">
<label>Created Date *</label>
<input
type="date"
name="createdDate"
value={formData.createdDate}
onChange={handleChange}
required
/>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="createdBy">Created By</label>
<select
className="form-control"
id="createdBy"
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>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="createdDate">Created Date</label>
<input
className="form-control"
type="date"
id="createdDate"
name="createdDate"
value={formData.createdDate}
onChange={handleChange}
required
/>
</div>
</div>
</div>
</div>
</div>
{/* 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",
}}
/>
{/* Notes Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Notes</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-12">
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea
className="form-control"
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows="5"
placeholder="Additional notes or details"
/>
</div>
</div>
</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>
{/* Form Actions */}
<div className="form-actions">
<button
type="button"
className="btn btn-default"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
</form>
</section>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
// src/pages/followups/followUpApi.js
import { get, post } from "../../apiUtil";
import { API_URL } from "../../constants";
import { get, post } from "../../../apiUtil";
import { API_URL } from "../../../constants";
export const fetchFollowUpsPaged = async ({
search = "",
@ -9,30 +9,30 @@ export const fetchFollowUpsPaged = async ({
status = "Open"
}) => {
const res = await get(
`${API_URL}/FollowUp/GetFollowUps?search=${encodeURIComponent(
`${API_URL}/FollowUp/GetFollowUpList?search=${encodeURIComponent(
search
)}&page=${page}&pageSize=${pageSize}&status=${status}`
);
const rows = (res?.Data || []).map(x => ({
id: x.Id,
reason: x.Reason,
scheduleDate: x.ScheduleDate,
scheduleTime: x.ScheduleStartTime,
account: x.AccountName,
assignedTo: x.EmployeeName,
locationNumber: x.LocationNumber,
locationName: x.LocationName,
workOrderCode: x.WorkOrderCode,
status: x.Status
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id ?? x.Id,
reason: x.reason ?? x.Reason ?? "",
scheduleDate: x.scheduleDate ?? x.ScheduleDate ?? "",
scheduleTime: x.scheduleStartTime ?? x.ScheduleStartTime ?? "",
account: x.accountName ?? x.AccountName ?? "",
assignedTo: x.employeeName ?? x.EmployeeName ?? "",
locationNumber: x.locationNumber ?? x.LocationNumber ?? "",
locationName: x.locationName ?? x.LocationName ?? "",
workOrderCode: x.workOrderCode ?? x.WorkOrderCode ?? "",
status: x.status ?? x.Status ?? ""
}));
return {
rows,
page: res.PageNumber,
pageSize: res.PageSize,
totalCount: res.TotalCount,
totalPages: res.TotalPages
page: res?.pageNumber ?? res?.PageNumber ?? 1,
pageSize: res?.pageSize ?? res?.PageSize ?? pageSize,
totalCount: res?.totalCount ?? res?.TotalCount ?? rows.length,
totalPages: res?.totalPages ?? res?.TotalPages ?? 1
};
};

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { getFollowUps } from "../api";
import { useState, useEffect, useCallback, useMemo } from "react";
import { fetchFollowUpsPaged } from "./api";
const useFollowUps = () => {
const [rows, setRows] = useState([]);
@ -7,35 +7,41 @@ const useFollowUps = () => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const reload = async () => {
const reload = useCallback(() => {
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);
}
};
setErr("");
fetchFollowUpsPaged({ search, page, pageSize, status: "Open" })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
useEffect(() => {
reload();
}, [search, page, pageSize]);
}, [reload]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
return useMemo(
() => ({
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload]
);
};
export default useFollowUps;

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchContactsPaged } from "./api";
import { fetchFollowUpsPaged } from "./api";
export default function useItems() {
const [rows, setRows] = useState([]);
@ -13,7 +13,7 @@ export default function useItems() {
const reload = useCallback(() => {
setLoading(true);
setErr("");
fetchContactsPaged({ search, page, pageSize })
fetchFollowUpsPaged({ search, page, pageSize, status: "Open" })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);

View file

@ -1,73 +1,49 @@
// 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",
},
];
import { locationApi } from "../../services/api";
export const getLocations = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockLocations];
export const getLocations = async (params = {}) => {
try {
const response = await locationApi.getAll(params);
const locations = response.data || response;
return Array.isArray(locations) ? locations : [];
} catch (error) {
console.error("Error fetching locations:", error);
throw error;
}
};
export const getLocationById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockLocations.find(l => l.id === parseInt(id));
try {
return await locationApi.getById(id);
} catch (error) {
console.error("Error fetching location:", error);
throw error;
}
};
export const addLocation = async (location) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newLocation = { id: mockLocations.length + 1, ...location };
mockLocations.push(newLocation);
return newLocation;
try {
return await locationApi.create(location);
} catch (error) {
console.error("Error creating location:", error);
throw error;
}
};
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;
try {
return await locationApi.update(location.id, location);
} catch (error) {
console.error("Error updating location:", error);
throw error;
}
};
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;
try {
await locationApi.delete(id);
return true;
} catch (error) {
console.error("Error deleting location:", error);
throw error;
}
};

View file

@ -0,0 +1,289 @@
/* Location Form Page - Matching Employee Form Style */
.locationFormPage {
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-4 {
width: 33.33333333%;
}
.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;
}
/* 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;
}
textarea.form-control {
height: auto;
min-height: 100px;
}
/* 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-default {
color: #333;
background-color: #fff;
border-color: #ccc;
}
.btn-default:hover {
background-color: #e6e6e6;
border-color: #adadad;
}
.btn-primary {
color: #fff;
background-color: #0088cc;
border-color: #0088cc;
}
.btn-primary:hover {
background-color: #006699;
border-color: #005580;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
/* Responsive */
@media (max-width: 767px) {
[class*="col-sm-"] {
width: 100%;
}
}

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getLocationById, addLocation, editLocation } from "../api";
import "./LocationFormPage.css";
export default function LocationFormPage() {
const { id } = useParams();
@ -74,180 +75,241 @@ export default function LocationFormPage() {
};
return (
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit Location" : "Create Location"}
</div>
<div className="locationFormPage">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); navigate("/locations"); }}>
Locations
</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<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 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 a Location" : "Create a Location"}</span>
</h3>
</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"
/>
{/* Location Information Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Location Information</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="name">Name</label>
<input
className="form-control"
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Location name"
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="accountId">Account</label>
<input
className="form-control"
type="text"
id="accountId"
name="accountId"
value={formData.accountId}
onChange={handleChange}
placeholder="Account ID or name"
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-12">
<div className="form-group">
<label className="required-field" htmlFor="address">Address</label>
<input
className="form-control"
type="text"
id="address"
name="address"
value={formData.address}
onChange={handleChange}
placeholder="Street address"
required
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="city">City</label>
<input
className="form-control"
type="text"
id="city"
name="city"
value={formData.city}
onChange={handleChange}
placeholder="City"
required
/>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="state">State</label>
<select
className="form-control"
id="state"
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>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="zipCode">Zip Code</label>
<input
className="form-control"
type="text"
id="zipCode"
name="zipCode"
value={formData.zipCode}
onChange={handleChange}
placeholder="12345"
required
/>
</div>
</div>
</div>
</div>
<div className="formRow">
<div className="formGroup">
<label>City *</label>
<input
type="text"
name="city"
value={formData.city}
onChange={handleChange}
required
placeholder="City"
/>
{/* Contact Information Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Contact Information</strong>
</h5>
</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 className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="phone">Phone</label>
<input
className="form-control"
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="(555) 000-0000"
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="contact">Contact Person</label>
<input
className="form-control"
type="text"
id="contact"
name="contact"
value={formData.contact}
onChange={handleChange}
placeholder="Full name"
required
/>
</div>
</div>
</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 className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="contactEmail">Contact Email</label>
<input
className="form-control"
type="email"
id="contactEmail"
name="contactEmail"
value={formData.contactEmail}
onChange={handleChange}
placeholder="email@company.com"
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="status">Status</label>
<select
className="form-control"
id="status"
name="status"
value={formData.status}
onChange={handleChange}
required
>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Closed">Closed</option>
</select>
</div>
</div>
</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>
{/* Form Actions */}
<div className="form-actions">
<button
type="button"
className="btn btn-default"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</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>
</form>
</div>
</div>
</form>
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { get } from "../../../apiUtil";
import { API_URL } from "../../../constants";
export const fetchLocationsPaged = async ({ search = "", page = 1, pageSize = 12 }) => {
const res = await get(`${API_URL}/Location/GetLocationList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`);
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id ?? x.Id,
name: x.name ?? x.Name ?? "",
address: x.address ?? x.Address ?? "",
city: x.city ?? x.City ?? "",
state: x.state ?? x.State ?? "",
zipCode: x.zipCode ?? x.ZipCode ?? x.Zip ?? "",
phone: x.phone ?? x.Phone ?? "",
contact: x.contact ?? x.Contact ?? "",
contactEmail: x.contactEmail ?? x.ContactEmail ?? "",
status: x.status ?? x.Status ?? "",
accountId: x.accountId ?? x.AccountId ?? "",
}));
return {
rows,
page: res?.pageNumber ?? 1,
pageSize: res?.pageSize ?? pageSize,
totalCount: res?.totalCount ?? rows.length,
totalPages: res?.totalPages ?? 1,
};
};
export const deleteLocation = (id) =>
get(`${API_URL}/Location/DeleteLocation?id=${id}`);

View file

@ -1,42 +1,31 @@
import { useState, useEffect } from "react";
import { getLocations } from "../api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchLocationsPaged } from "./api";
const useLocations = () => {
export default function 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 [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const reload = async () => {
const reload = useCallback(() => {
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();
setErr("");
fetchLocationsPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
useEffect(() => { reload(); }, [reload]);
export default useLocations;
return useMemo(() => ({
rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err, reload
}), [rows, search, page, pageSize, totalCount, loading, err, reload]);
}

5
src/pages/user/api.js Normal file
View file

@ -0,0 +1,5 @@
import { post } from '../../apiUtil';
import { API_URL } from '../../constants';
export const login = (item) => post(`${API_URL}/Authentication/login`, item);

View file

@ -1,73 +1,49 @@
// 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,
},
];
import { workOrderApi } from "../../services/api";
export const getWorkOrders = async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return [...mockWorkOrders];
export const getWorkOrders = async (params = {}) => {
try {
const response = await workOrderApi.getAll(params);
const workOrders = response.data || response;
return Array.isArray(workOrders) ? workOrders : [];
} catch (error) {
console.error("Error fetching work orders:", error);
throw error;
}
};
export const getWorkOrderById = async (id) => {
await new Promise(resolve => setTimeout(resolve, 200));
return mockWorkOrders.find(w => w.id === parseInt(id));
try {
return await workOrderApi.getById(id);
} catch (error) {
console.error("Error fetching work order:", error);
throw error;
}
};
export const addWorkOrder = async (workOrder) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newWorkOrder = { id: mockWorkOrders.length + 1, ...workOrder };
mockWorkOrders.push(newWorkOrder);
return newWorkOrder;
try {
return await workOrderApi.create(workOrder);
} catch (error) {
console.error("Error creating work order:", error);
throw error;
}
};
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;
try {
return await workOrderApi.update(workOrder.id, workOrder);
} catch (error) {
console.error("Error updating work order:", error);
throw error;
}
};
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;
try {
await workOrderApi.delete(id);
return true;
} catch (error) {
console.error("Error deleting work order:", error);
throw error;
}
};

View file

@ -0,0 +1,289 @@
/* Work Order Form Page - Matching Employee Form Style */
.workOrderFormPage {
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-4 {
width: 33.33333333%;
}
.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;
}
/* 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;
}
textarea.form-control {
height: auto;
min-height: 100px;
}
/* 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-default {
color: #333;
background-color: #fff;
border-color: #ccc;
}
.btn-default:hover {
background-color: #e6e6e6;
border-color: #adadad;
}
.btn-primary {
color: #fff;
background-color: #0088cc;
border-color: #0088cc;
}
.btn-primary:hover {
background-color: #006699;
border-color: #005580;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
/* Responsive */
@media (max-width: 767px) {
[class*="col-sm-"] {
width: 100%;
}
}

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById, addWorkOrder, editWorkOrder } from "../api";
import "./WorkOrderFormPage.css";
export default function WorkOrderFormPage() {
const { id } = useParams();
@ -74,189 +75,254 @@ export default function WorkOrderFormPage() {
};
return (
<section className="formPage">
<div className="pageTitle">
{isEdit ? "Edit Work Order" : "Create Work Order"}
</div>
<div className="workOrderFormPage">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); navigate("/workorders"); }}>
Work Orders
</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<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 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 a Work Order" : "Create a Work Order"}</span>
</h3>
</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>
{/* Work Order Details Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Work Order Details</strong>
</h5>
</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 className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="workOrderNumber">Work Order Number</label>
<input
className="form-control"
type="text"
id="workOrderNumber"
name="workOrderNumber"
value={formData.workOrderNumber}
onChange={handleChange}
placeholder="e.g., WO-001"
required
/>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="title">Title</label>
<input
className="form-control"
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Work order title"
required
/>
</div>
</div>
</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 className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="location">Location</label>
<select
className="form-control"
id="location"
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>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="assignedTo">Assigned To</label>
<select
className="form-control"
id="assignedTo"
name="assignedTo"
value={formData.assignedTo}
onChange={handleChange}
>
<option value="">Select Employee</option>
<option value="John Anderson">John Anderson</option>
<option value="Sarah Martinez">Sarah Martinez</option>
<option value="Mike Johnson">Mike Johnson</option>
</select>
</div>
</div>
</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>
{/* Schedule & Priority Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Schedule & Priority</strong>
</h5>
</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 className="row">
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="createdDate">Created Date</label>
<input
className="form-control"
type="date"
id="createdDate"
name="createdDate"
value={formData.createdDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="dueDate">Due Date</label>
<input
className="form-control"
type="date"
id="dueDate"
name="dueDate"
value={formData.dueDate}
onChange={handleChange}
required
/>
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label className="required-field" htmlFor="estimatedHours">Estimated Hours</label>
<input
className="form-control"
type="number"
id="estimatedHours"
name="estimatedHours"
value={formData.estimatedHours}
onChange={handleChange}
required
min="0.5"
step="0.5"
/>
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="priority">Priority</label>
<select
className="form-control"
id="priority"
name="priority"
value={formData.priority}
onChange={handleChange}
required
>
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Urgent">Urgent</option>
</select>
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="status">Status</label>
<select
className="form-control"
id="status"
name="status"
value={formData.status}
onChange={handleChange}
required
>
<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>
</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",
}}
/>
{/* Description Section */}
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase">
<strong>Description</strong>
</h5>
</div>
<div className="row">
<div className="col-sm-12">
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
className="form-control"
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="5"
placeholder="Detailed description of the work to be performed"
/>
</div>
</div>
</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>
{/* Form Actions */}
<div className="form-actions">
<button
type="button"
className="btn btn-default"
onClick={handleCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
</form>
</section>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,28 @@
import { get } from "../../../apiUtil";
import { API_URL } from "../../../constants";
export const fetchWorkOrdersPaged = async ({ search = "", page = 1, pageSize = 12 }) => {
const res = await get(`${API_URL}/WorkOrder/GetWorkOrderList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`);
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id ?? x.Id,
number: x.workOrderNumber ?? x.WorkOrderNumber ?? x.number ?? "",
title: x.title ?? x.Title ?? "",
location: x.location ?? x.Location ?? x.locationName ?? x.LocationName ?? "",
priority: x.priority ?? x.Priority ?? "",
status: x.status ?? x.Status ?? "",
createdDate: x.createdDate ?? x.CreatedDate ?? "",
dueDate: x.dueDate ?? x.DueDate ?? "",
assignedTo: x.assignedTo ?? x.AssignedTo ?? x.employeeName ?? x.EmployeeName ?? "",
}));
return {
rows,
page: res?.pageNumber ?? 1,
pageSize: res?.pageSize ?? pageSize,
totalCount: res?.totalCount ?? rows.length,
totalPages: res?.totalPages ?? 1,
};
};
export const deleteWorkOrder = (id) =>
get(`${API_URL}/WorkOrder/DeleteWorkOrder?id=${id}`);

View file

@ -1,42 +1,31 @@
import { useState, useEffect } from "react";
import { getWorkOrders } from "../api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchWorkOrdersPaged } from "./api";
const useWorkOrders = () => {
export default function 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 [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const reload = async () => {
const reload = useCallback(() => {
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();
setErr("");
fetchWorkOrdersPaged({ search, page, pageSize })
.then(({ rows, totalCount }) => {
setRows(rows);
setTotalCount(totalCount);
})
.catch((e) => setErr(e.message || "Failed to load"))
.finally(() => setLoading(false));
}, [search, page, pageSize]);
return { rows, search, setSearch, page, setPage, pageSize, setPageSize, totalCount, loading, err, reload };
};
useEffect(() => { reload(); }, [reload]);
export default useWorkOrders;
return useMemo(() => ({
rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err, reload
}), [rows, search, page, pageSize, totalCount, loading, err, reload]);
}

134
src/services/api.js Normal file
View file

@ -0,0 +1,134 @@
// API Base Configuration
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000';
const API_PREFIX = '/api';
// HTTP Methods Helper
class ApiService {
constructor(baseURL = API_BASE_URL, prefix = API_PREFIX) {
this.baseURL = baseURL;
this.prefix = prefix;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${this.prefix}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP Error: ${response.status}`);
}
// Handle 204 No Content
if (response.status === 204) {
return null;
}
return await response.json();
} catch (error) {
console.error('API Request Error:', error);
throw error;
}
}
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, { method: 'GET' });
}
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
async put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
}
// Create singleton instance
const api = new ApiService();
// Employee API
export const employeeApi = {
getAll: (params) => api.get('/employees', params),
getById: (id) => api.get(`/employees/${id}`),
create: (data) => api.post('/employees', data),
update: (id, data) => api.put(`/employees/${id}`, data),
delete: (id) => api.delete(`/employees/${id}`),
};
// Contact API
export const contactApi = {
getAll: (params) => api.get('/contacts', params),
getById: (id) => api.get(`/contacts/${id}`),
create: (data) => api.post('/contacts', data),
update: (id, data) => api.put(`/contacts/${id}`, data),
delete: (id) => api.delete(`/contacts/${id}`),
};
// Account API
export const accountApi = {
getAll: (params) => api.get('/accounts', params),
getById: (id) => api.get(`/accounts/${id}`),
create: (data) => api.post('/accounts', data),
update: (id, data) => api.put(`/accounts/${id}`, data),
delete: (id) => api.delete(`/accounts/${id}`),
};
// Location API
export const locationApi = {
getAll: (params) => api.get('/locations', params),
getById: (id) => api.get(`/locations/${id}`),
create: (data) => api.post('/locations', data),
update: (id, data) => api.put(`/locations/${id}`, data),
delete: (id) => api.delete(`/locations/${id}`),
};
// Work Order API
export const workOrderApi = {
getAll: (params) => api.get('/workorders', params),
getById: (id) => api.get(`/workorders/${id}`),
create: (data) => api.post('/workorders', data),
update: (id, data) => api.put(`/workorders/${id}`, data),
delete: (id) => api.delete(`/workorders/${id}`),
};
// PM Schedule API
export const pmScheduleApi = {
getAll: (params) => api.get('/pmschedules', params),
getById: (id) => api.get(`/pmschedules/${id}`),
create: (data) => api.post('/pmschedules', data),
update: (id, data) => api.put(`/pmschedules/${id}`, data),
delete: (id) => api.delete(`/pmschedules/${id}`),
};
// Follow-up API
export const followupApi = {
getAll: (params) => api.get('/followups', params),
getById: (id) => api.get(`/followups/${id}`),
create: (data) => api.post('/followups', data),
update: (id, data) => api.put(`/followups/${id}`, data),
delete: (id) => api.delete(`/followups/${id}`),
};
export default api;

177
src/services/authService.js Normal file
View file

@ -0,0 +1,177 @@
// Authentication Service
const API_BASE_URL = process.env.REACT_APP_API_URL || "http://localhost:5000";
/**
* Login user with username and password
* @param {string} username - User's email/username
* @param {string} password - User's password
* @returns {Promise<Object>} - Authentication response with token and user data
*/
export async function login(username, password) {
const response = await fetch(`${API_BASE_URL}/api/Authentication/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
Username: username,
Password: password,
}),
});
if (!response.ok) {
if (response.status === 401) {
throw new Error("Invalid credentials");
}
throw new Error("Login failed");
}
const data = await response.json();
return data;
}
/**
* Logout user - clear local storage
*/
export function logout() {
localStorage.removeItem("token");
localStorage.removeItem("user");
}
/**
* Check if user is authenticated
* @returns {boolean}
*/
export function isAuthenticated() {
const token = localStorage.getItem("token");
if (!token) return false;
// Check if token is expired
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const expiration = new Date(payload.exp * 1000);
return expiration > new Date();
} catch (e) {
return false;
}
}
/**
* Get current user from local storage
* @returns {Object|null}
*/
export function getCurrentUser() {
const userStr = localStorage.getItem("user");
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch (e) {
return null;
}
}
/**
* Get authentication token
* @returns {string|null}
*/
export function getToken() {
return localStorage.getItem("token");
}
/**
* Change user password
* @param {string} currentPassword - Current password
* @param {string} newPassword - New password
* @param {string} confirmPassword - Confirm new password
* @returns {Promise<Object>}
*/
export async function changePassword(currentPassword, newPassword, confirmPassword) {
const token = getToken();
const response = await fetch(`${API_BASE_URL}/api/Authentication/ChangePassword`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
Currentpassword: currentPassword,
Newpassword: newPassword,
Confirmpassword: confirmPassword,
}),
});
if (!response.ok) {
throw new Error("Failed to change password");
}
return await response.json();
}
/**
* Request password reset
* @param {string} email - User's email
* @returns {Promise<Object>}
*/
export async function forgotPassword(email) {
const response = await fetch(`${API_BASE_URL}/api/Authentication/ForgetPassword?Email=${encodeURIComponent(email)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to send password reset email");
}
return await response.json();
}
/**
* Verify password reset code
* @param {string} code - Verification code
* @returns {Promise<Object>}
*/
export async function verifyResetCode(code) {
const response = await fetch(`${API_BASE_URL}/api/Authentication/VerificationCode?code=${encodeURIComponent(code)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Invalid verification code");
}
return await response.json();
}
/**
* Reset password with code
* @param {string} email - User's email
* @param {string} code - Verification code
* @param {string} password - New password
* @returns {Promise<Object>}
*/
export async function resetPassword(email, code, password) {
const response = await fetch(`${API_BASE_URL}/api/Authentication/ResetPassword`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
Email: email,
Code: code,
Password: password,
}),
});
if (!response.ok) {
throw new Error("Failed to reset password");
}
return await response.json();
}