Implement the Slot Management UI
This commit is contained in:
parent
ef2e6a3b51
commit
25f4563484
|
@ -43,7 +43,7 @@ export default function MenuContent({ hidden }: PropType) {
|
|||
text: "Users",
|
||||
icon: <AnalyticsRoundedIcon />,
|
||||
url: "/panel/user-list",
|
||||
},
|
||||
},
|
||||
userRole === "admin" && {
|
||||
text: "Charging Stations",
|
||||
icon: <ManageAccountsOutlinedIcon />,
|
||||
|
@ -54,13 +54,17 @@ export default function MenuContent({ hidden }: PropType) {
|
|||
icon: <ManageAccountsOutlinedIcon />,
|
||||
url: "/panel/manager-list", // Placeholder for now
|
||||
},
|
||||
|
||||
|
||||
userRole === "admin" && {
|
||||
text: "Vehicles",
|
||||
icon: <ManageAccountsOutlinedIcon />,
|
||||
url: "/panel/vehicle-list", // Placeholder for now
|
||||
},
|
||||
|
||||
userRole === "manager" && {
|
||||
text: "Add Slots",
|
||||
icon: <ManageAccountsOutlinedIcon />,
|
||||
url: "/panel/EVslots", // Placeholder for now
|
||||
},
|
||||
];
|
||||
|
||||
const filteredMenuItems = baseMenuItems.filter(Boolean);
|
||||
|
|
379
src/pages/EVSlotManagement/index.tsx
Normal file
379
src/pages/EVSlotManagement/index.tsx
Normal file
|
@ -0,0 +1,379 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Typography,
|
||||
IconButton,
|
||||
Stack,
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import { CustomTextField } from "../../components/AddEditUserModel/styled.css.tsx";
|
||||
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CalendarTodayRoundedIcon from "@mui/icons-material/CalendarTodayRounded";
|
||||
import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
const days = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
];
|
||||
|
||||
export default function EVSlotManagement() {
|
||||
const [selectedDay, setSelectedDay] = useState("Monday");
|
||||
const [openingTime, setOpeningTime] = useState("");
|
||||
const [closingTime, setClosingTime] = useState("");
|
||||
const [slots, setSlots] = useState<any>({});
|
||||
const [breakTime, setBreakTime] = useState<any>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs | null>(
|
||||
dayjs()
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const handleBack = () => {
|
||||
navigate("/panel/dashboard"); // Navigate back to Role List
|
||||
};
|
||||
// Add slot function with basic validation
|
||||
const addSlot = (start: string, end: string) => {
|
||||
if (!start || !end) {
|
||||
setError("Start and End times are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (slots[selectedDay]) {
|
||||
// Check if start time is before end time
|
||||
const newSlot = { start, end };
|
||||
const overlap = slots[selectedDay].some(
|
||||
(slot: any) =>
|
||||
(slot.start <= newSlot.start && slot.end > newSlot.start) ||
|
||||
(slot.start < newSlot.end && slot.end >= newSlot.end)
|
||||
);
|
||||
|
||||
if (overlap) {
|
||||
setError("The selected time overlaps with an existing slot.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
slots[selectedDay] = [];
|
||||
}
|
||||
|
||||
slots[selectedDay].push({ start, end });
|
||||
setSlots({ ...slots });
|
||||
setError("");
|
||||
};
|
||||
|
||||
// Add break time function
|
||||
const addBreak = (start: string, end: string) => {
|
||||
if (!start || !end) {
|
||||
setError("Break Start and End times are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBreakTime([...breakTime, { start, end }]);
|
||||
setError("");
|
||||
};
|
||||
|
||||
// Delete slot function
|
||||
const deleteSlot = (start: string, end: string) => {
|
||||
const updatedSlots = slots[selectedDay].filter(
|
||||
(slot: any) => !(slot.start === start && slot.end === end)
|
||||
);
|
||||
setSlots({
|
||||
...slots,
|
||||
[selectedDay]: updatedSlots,
|
||||
});
|
||||
};
|
||||
|
||||
// Delete break function
|
||||
const deleteBreak = (start: string, end: string) => {
|
||||
const updatedBreaks = breakTime.filter(
|
||||
(breakItem: any) =>
|
||||
!(breakItem.start === start && breakItem.end === end)
|
||||
);
|
||||
setBreakTime(updatedBreaks);
|
||||
};
|
||||
|
||||
// Save function
|
||||
const saveData = () => {
|
||||
if (!openingTime || !closingTime) {
|
||||
setError("Operating hours are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate saving data (e.g., to a database or API)
|
||||
setSuccessMessage(
|
||||
`Data for ${selectedDay} has been saved successfully!`
|
||||
);
|
||||
setError(""); // Clear any previous errors
|
||||
};
|
||||
|
||||
// Close the success message
|
||||
const handleCloseSnackbar = () => {
|
||||
setSuccessMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 600, mx: "auto", p: 2 }}>
|
||||
<Typography variant="h4" gutterBottom align="center">
|
||||
EV Station Slot Management
|
||||
</Typography>
|
||||
{/* Date Picker */}
|
||||
<Box
|
||||
sx={{ display: "flex", justifyContent: "space-between", mt: 3 }}
|
||||
>
|
||||
<Typography variant="h4" gutterBottom align="center">
|
||||
Select Date
|
||||
</Typography>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={(newDate) => setSelectedDate(newDate)}
|
||||
renderInput={(props) => (
|
||||
<CustomTextField
|
||||
{...props}
|
||||
fullWidth
|
||||
label="Select Date"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<CalendarTodayRoundedIcon fontSize="small" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
</Box>
|
||||
<Tabs
|
||||
value={selectedDay}
|
||||
onChange={(event, newValue) => setSelectedDay(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{days.map((day) => (
|
||||
<Tab
|
||||
key={day}
|
||||
label={day}
|
||||
value={day}
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "16px",
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: "#52ACDF",
|
||||
color: "white",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<Card sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Set Operating Hours for {selectedDay}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2, mt: 1 }}>
|
||||
<CustomTextField
|
||||
type="time"
|
||||
label="Opening Time"
|
||||
value={openingTime}
|
||||
onChange={(e) => setOpeningTime(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<CustomTextField
|
||||
type="time"
|
||||
label="Closing Time"
|
||||
value={closingTime}
|
||||
onChange={(e) => setClosingTime(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6">Add Slots</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2, mt: 1 }}>
|
||||
<CustomTextField
|
||||
type="time"
|
||||
label="Start Time"
|
||||
id="slotStart"
|
||||
fullWidth
|
||||
/>
|
||||
<CustomTextField
|
||||
type="time"
|
||||
label="End Time"
|
||||
id="slotEnd"
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
sx={{
|
||||
backgroundColor: "#52ACDF",
|
||||
color: "white",
|
||||
borderRadius: "8px",
|
||||
width: "250px",
|
||||
"&:hover": { backgroundColor: "#439BC1" },
|
||||
}}
|
||||
onClick={() =>
|
||||
addSlot(
|
||||
document.getElementById("slotStart").value,
|
||||
document.getElementById("slotEnd").value
|
||||
)
|
||||
}
|
||||
>
|
||||
<AddCircleIcon sx={{ mr: 1 }} /> Add
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography color="error" mt={1}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6">Break Time</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2, mt: 1 }}>
|
||||
<CustomTextField
|
||||
type="time"
|
||||
label="Break Start"
|
||||
id="breakStart"
|
||||
fullWidth
|
||||
/>
|
||||
<CustomTextField
|
||||
type="time"
|
||||
label="Break End"
|
||||
id="breakEnd"
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
sx={{
|
||||
backgroundColor: "#52ACDF",
|
||||
color: "white",
|
||||
borderRadius: "8px",
|
||||
width: "250px",
|
||||
"&:hover": { backgroundColor: "#439BC1" },
|
||||
}}
|
||||
onClick={() =>
|
||||
addBreak(
|
||||
document.getElementById("breakStart").value,
|
||||
document.getElementById("breakEnd").value
|
||||
)
|
||||
}
|
||||
>
|
||||
<AddCircleIcon sx={{ mr: 1 }} /> Add
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Slots for {selectedDay}
|
||||
</Typography>
|
||||
{slots[selectedDay]?.length ? (
|
||||
slots[selectedDay].map((slot: any, index: number) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
{slot.start} - {slot.end}
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={() => deleteSlot(slot.start, slot.end)}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography>No slots added</Typography>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Break Times for {selectedDay}
|
||||
</Typography>
|
||||
{breakTime.length ? (
|
||||
breakTime.map((breakItem: any, index: number) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
{breakItem.start} - {breakItem.end}
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
deleteBreak(breakItem.start, breakItem.end)
|
||||
}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography>No break times added</Typography>
|
||||
)}
|
||||
</Card>
|
||||
<Box
|
||||
sx={{ display: "flex", justifyContent: "space-between", mt: 3 }}
|
||||
>
|
||||
<Button
|
||||
sx={{
|
||||
backgroundColor: "#52ACDF",
|
||||
color: "white",
|
||||
borderRadius: "8px",
|
||||
"&:hover": { backgroundColor: "#439BC1" },
|
||||
width: "117px",
|
||||
}}
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{/* Save Button */}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
sx={{
|
||||
backgroundColor: "#52ACDF",
|
||||
color: "white",
|
||||
borderRadius: "8px",
|
||||
width: "117px",
|
||||
"&:hover": { backgroundColor: "#439BC1" },
|
||||
}}
|
||||
onClick={saveData}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
{/* Success Snackbar */}
|
||||
<Snackbar
|
||||
open={!!successMessage}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseSnackbar}
|
||||
message={successMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -8,6 +8,7 @@ import roleReducer from "./slices/roleSlice.ts";
|
|||
import vehicleReducer from "./slices/VehicleSlice.ts";
|
||||
import managerReducer from "../redux/slices/managerSlice.ts";
|
||||
import stationReducer from "../redux/slices/stationSlice.ts";
|
||||
import slotReducer from "../redux/slices/slotSlice.ts";
|
||||
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
|
@ -18,7 +19,8 @@ const rootReducer = combineReducers({
|
|||
roleReducer,
|
||||
vehicleReducer,
|
||||
managerReducer,
|
||||
stationReducer
|
||||
stationReducer,
|
||||
slotReducer,
|
||||
// Add other reducers here...
|
||||
});
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ interface Admin {
|
|||
}
|
||||
|
||||
interface AuthState {
|
||||
managerId: any;
|
||||
user: User | null;
|
||||
admins: Admin[];
|
||||
isAuthenticated: boolean;
|
||||
|
|
190
src/redux/slices/slotSlice.ts
Normal file
190
src/redux/slices/slotSlice.ts
Normal file
|
@ -0,0 +1,190 @@
|
|||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import http from "../../lib/https"; // Assuming you have a custom HTTP library for requests
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Define TypeScript types
|
||||
interface Slot {
|
||||
id: number;
|
||||
stationId: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
status: number;
|
||||
}
|
||||
|
||||
|
||||
interface SlotState {
|
||||
slots: Slot[];
|
||||
availableSlots: Slot[]; // Ensure it's initialized as an empty array
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: SlotState = {
|
||||
slots: [],
|
||||
availableSlots: [], // <-- Initialize this as an empty array
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Fetch Available Slots
|
||||
export const fetchAvailableSlots = createAsyncThunk<
|
||||
Slot[], // Return type
|
||||
number, // Argument type (stationId)
|
||||
{ rejectValue: string }
|
||||
>("slots/fetchAvailableSlots", async (stationId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await http.get("/available", { stationId });
|
||||
return response.data.data; // Return the available slot data
|
||||
} catch (error: any) {
|
||||
toast.error("Error fetching available slots: " + error?.message);
|
||||
return rejectWithValue(error.response?.data?.message || "An error occurred");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const createSlot = createAsyncThunk<
|
||||
Slot, // Return type (Slot)
|
||||
{
|
||||
stationId: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAvailable: boolean;
|
||||
}, // Argument type (slot data)
|
||||
{ rejectValue: string } // Error type
|
||||
>("slots/createSlot", async (slotData, { rejectWithValue }) => {
|
||||
try {
|
||||
const { stationId, startTime, endTime, isAvailable } = slotData;
|
||||
|
||||
// Ensure that the start and end time are correctly formatted in ISO 8601
|
||||
const startDateTime = new Date(startTime).toISOString(); // convert to ISO string
|
||||
const endDateTime = new Date(endTime).toISOString(); // convert to ISO string
|
||||
|
||||
// Make sure the formatted times are valid
|
||||
if (
|
||||
isNaN(new Date(startDateTime).getTime()) ||
|
||||
isNaN(new Date(endDateTime).getTime())
|
||||
) {
|
||||
throw new Error("Invalid date format");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
stationId,
|
||||
startTime: startDateTime,
|
||||
endTime: endDateTime,
|
||||
isAvailable,
|
||||
};
|
||||
|
||||
// Send the request to create the slot
|
||||
const response = await http.post("/create-slot", payload);
|
||||
|
||||
// Show success message
|
||||
toast.success("Slot created successfully");
|
||||
|
||||
// Return the response data (created slot)
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
// Show error message
|
||||
toast.error("Error creating slot: " + error?.message);
|
||||
|
||||
// Return a detailed error message if possible
|
||||
return rejectWithValue(
|
||||
error.response?.data?.message || "An error occurred"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Update Slot details
|
||||
export const updateSlot = createAsyncThunk<
|
||||
Slot, // Return type
|
||||
{ id: number; startTime: string; endTime: string }, // Argument type (slot update data)
|
||||
{ rejectValue: string }
|
||||
>("slots/updateSlot", async ({ id, ...slotData }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await http.patch(`/slots/${id}`, slotData);
|
||||
toast.success("Slot updated successfully");
|
||||
return response.data.data; // Return updated slot data
|
||||
} catch (error: any) {
|
||||
toast.error("Error updating the slot: " + error?.message);
|
||||
return rejectWithValue(error.response?.data?.message || "An error occurred");
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Slot
|
||||
export const deleteSlot = createAsyncThunk<
|
||||
number, // Return type (id of deleted slot)
|
||||
number, // Argument type (id of the slot)
|
||||
{ rejectValue: string }
|
||||
>("slots/deleteSlot", async (id, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await http.delete(`/slots/${id}`);
|
||||
toast.success("Slot deleted successfully");
|
||||
return id; // Return the id of the deleted slot
|
||||
} catch (error: any) {
|
||||
toast.error("Error deleting the slot: " + error?.message);
|
||||
return rejectWithValue(error.response?.data?.message || "An error occurred");
|
||||
}
|
||||
});
|
||||
|
||||
const slotSlice = createSlice({
|
||||
name: "slots",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchAvailableSlots.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchAvailableSlots.fulfilled, (state, action: PayloadAction<Slot[]>) => {
|
||||
state.loading = false;
|
||||
state.availableSlots = action.payload;
|
||||
})
|
||||
.addCase(fetchAvailableSlots.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload || "Failed to fetch available slots";
|
||||
})
|
||||
.addCase(createSlot.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(createSlot.fulfilled, (state, action: PayloadAction<Slot>) => {
|
||||
state.loading = false;
|
||||
state.slots.push(action.payload);
|
||||
})
|
||||
.addCase(createSlot.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload || "Failed to create slot";
|
||||
})
|
||||
.addCase(updateSlot.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(updateSlot.fulfilled, (state, action: PayloadAction<Slot>) => {
|
||||
state.loading = false;
|
||||
// Update the slot in the state with the updated data
|
||||
const index = state.slots.findIndex((slot) => slot.id === action.payload.id);
|
||||
if (index !== -1) {
|
||||
state.slots[index] = action.payload;
|
||||
}
|
||||
})
|
||||
.addCase(updateSlot.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload || "Failed to update slot";
|
||||
})
|
||||
.addCase(deleteSlot.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(deleteSlot.fulfilled, (state, action: PayloadAction<number>) => {
|
||||
state.loading = false;
|
||||
// Remove the deleted slot from the state
|
||||
state.slots = state.slots.filter((slot) => slot.id !== action.payload);
|
||||
})
|
||||
.addCase(deleteSlot.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload || "Failed to delete slot";
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default slotSlice.reducer;
|
|
@ -19,6 +19,7 @@ const AddEditRolePage = lazy(() => import("./pages/AddEditRolePage"));
|
|||
const RoleList = lazy(() => import("./pages/RoleList"));
|
||||
const ManagerList = lazy(() => import("./pages/ManagerList"));
|
||||
const StationList = lazy(() => import("./pages/StationList"));
|
||||
const EVSlotManagement = lazy(() => import("./pages/EVSlotManagement"));
|
||||
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
|
@ -91,6 +92,12 @@ export default function AppRouter() {
|
|||
path="profile"
|
||||
element={<ProtectedRoute component={<ProfilePage />} />}
|
||||
/>
|
||||
<Route
|
||||
path="EVslots"
|
||||
element={
|
||||
<ProtectedRoute component={<EVSlotManagement />} />
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch-all Route */}
|
||||
|
|
Loading…
Reference in a new issue