diff --git a/.env b/.env new file mode 100644 index 000000000..bc95901a9 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +REACT_APP_API_URL=http://127.0.0.1:5000/api diff --git a/package.json b/package.json index 086c368c9..86afd5ad3 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,11 @@ "@fontsource/open-sans": "^4.5.0", "@fontsource/raleway": "^4.5.0", "@fontsource/roboto": "^4.5.8", + "@reduxjs/toolkit": "^2.6.1", "ajv": "^8.17.1", - "apexcharts": "^3.27.3", + "apexcharts": "^4.0.0", + "axios": "^1.8.4", + "chart.js": "^4.4.8", "classnames": "2.3.1", "cross-env": "^7.0.3", "framer-motion": "^4.1.17", @@ -28,17 +31,21 @@ "react-big-calendar": "0.33.2", "react-bootstrap-sweetalert": "5.2.0", "react-datetime": "3.1.1", + "react-datetime-picker": "^6.0.1", "react-dom": "16.14.0", "react-github-btn": "^1.2.1", "react-icons": "^4.2.0", "react-jvectormap": "0.0.16", + "react-redux": "^7.2.9", "react-router-dom": "5.2.1", "react-scripts": "5.0.0", "react-swipeable-views": "0.14.0", "react-table": "7.7.0", "react-tagsinput": "3.19.0", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "stylis": "^4.0.13", - "stylis-plugin-rtl": "^2.1.1" + "stylis-plugin-rtl": "2.1.1" }, "resolutions": { "react-error-overlay": "6.0.11" diff --git a/src/components/Icons/Icons.js b/src/components/Icons/Icons.js index e9c186e9a..16d7d18ec 100644 --- a/src/components/Icons/Icons.js +++ b/src/components/Icons/Icons.js @@ -109,17 +109,6 @@ export const CreativeTimLogo = createIcon({ ), - - // - // }); export const CreditIcon = createIcon({ @@ -339,7 +328,7 @@ export const MastercardIcon = createIcon({ @@ -360,7 +349,7 @@ export const PayPalIcon = createIcon({ /> ), @@ -372,7 +361,7 @@ export const PersonIcon = createIcon({ path: ( ), }); @@ -402,7 +391,7 @@ export const RocketIcon = createIcon({ /> ), @@ -411,13 +400,12 @@ export const RocketIcon = createIcon({ export const SettingsIcon = createIcon({ displayName: "SettingsIcon", viewBox: "0 0 24 24", - // path can also be an array of elements, if you have multiple paths, lines, shapes, etc. path: ( ), @@ -429,7 +417,7 @@ export const SlackLogo = createIcon({ path: ( ), }); + +export const ActivityIcon = createIcon({ + displayName: "ActivityIcon", + viewBox: "0 0 24 24", + path: ( + + ), +}); diff --git a/src/components/Products/ProductCreateModal.js b/src/components/Products/ProductCreateModal.js new file mode 100644 index 000000000..7f1e9f9e5 --- /dev/null +++ b/src/components/Products/ProductCreateModal.js @@ -0,0 +1,121 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Button, + FormControl, + FormLabel, + Input, + Textarea, + VStack, + useColorModeValue, +} from "@chakra-ui/react"; +import React, { useState } from "react"; + +function ProductCreateModal({ isOpen, onClose, onCreateProduct }) { + const textColor = useColorModeValue("gray.700", "white"); + const [formData, setFormData] = useState({ + title: "", + sku: "", + price: "", + description: "", + image: "", + }); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + onCreateProduct(formData); + setFormData({ + title: "", + sku: "", + price: "", + description: "", + image: "", + }); + onClose(); + }; + + return ( + + + + Create New Product + +
+ + + + Title + + + + SKU + + + + Price + + + + Description + + + + Image URL + + + + + + + + +
+
+
+ ); +} + +export default ProductCreateModal; \ No newline at end of file diff --git a/src/components/Products/ProductEditModal.js b/src/components/Products/ProductEditModal.js new file mode 100644 index 000000000..90d412c6c --- /dev/null +++ b/src/components/Products/ProductEditModal.js @@ -0,0 +1,175 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Button, + FormControl, + FormLabel, + Input, + Textarea, + VStack, + useColorModeValue, + Image, + Box, + Text, + FormErrorMessage, +} from "@chakra-ui/react"; +import React, { useState, useEffect } from "react"; + +function ProductEditModal({ isOpen, onClose, onEditProduct, product }) { + const textColor = useColorModeValue("gray.700", "white"); + const [formData, setFormData] = useState({ + title: "", + sku: "", + price: "", + description: "", + image: "", + }); + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (product) { + setFormData({ + title: product.title || "", + sku: product.sku || "", + price: product.price || "", + description: product.description || "", + image: product.image || "", + }); + setErrors({}); + } + }, [product]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const validateForm = () => { + const newErrors = {}; + if (!formData.title.trim()) newErrors.title = "Title is required"; + if (!formData.sku.trim()) newErrors.sku = "SKU is required"; + if (!formData.price) newErrors.price = "Price is required"; + if (!formData.description.trim()) newErrors.description = "Description is required"; + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!validateForm()) return; + setIsLoading(true); + try { + await onEditProduct(product.shopify_id, formData); + onClose(); + } catch (error) { + console.error("Error updating product:", error); + } finally { + setIsLoading(false); + } + }; + + if (!product) return null; + + return ( + + + + Edit Product + +
+ + + + Title + + {errors.title} + + + + SKU + + {errors.sku} + + + + Price + + {errors.price} + + + + Description + + {errors.description} + + + + Image URL + + {formData.image && ( + + + + )} + + + + + + + +
+
+
+ ); +} + +export default ProductEditModal; \ No newline at end of file diff --git a/src/components/Products/ProductViewModal.js b/src/components/Products/ProductViewModal.js new file mode 100644 index 000000000..c098cf267 --- /dev/null +++ b/src/components/Products/ProductViewModal.js @@ -0,0 +1,62 @@ +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + Button, + Image, + Text, + VStack, + HStack, + useColorModeValue, +} from "@chakra-ui/react"; +import React from "react"; + +function ProductViewModal({ isOpen, onClose, product }) { + const textColor = useColorModeValue("gray.700", "white"); + + if (!product) return null; + + return ( + + + + Product Details + + + + + + {product.title} + + + + SKU: {product.sku} + + + ${product.price} + + + {product.description} + + + + + + + + ); +} + +export default ProductViewModal; \ No newline at end of file diff --git a/src/components/Tables/ProductTableRow.js b/src/components/Tables/ProductTableRow.js new file mode 100644 index 000000000..efa96886b --- /dev/null +++ b/src/components/Tables/ProductTableRow.js @@ -0,0 +1,66 @@ +import { + Avatar, + Button, + Flex, + Td, + Text, + Tr, + useColorModeValue, +} from "@chakra-ui/react"; +import React from "react"; +import { FaEdit, FaTrash } from "react-icons/fa"; + +function ProductTableRow(props) { + const { id, image, title, sku, price, description, onView, onEdit, onDelete } = props; + const textColor = useColorModeValue("gray.700", "white"); + + return ( +
+ + + + + + + + + {title} + + + {description} + + + + + + {sku} + + + + + ${price} + + + + + + + + +
+ ); +} + +export default ProductTableRow; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 855c8ed16..220675dc8 100644 --- a/src/index.js +++ b/src/index.js @@ -18,19 +18,26 @@ import React from "react"; import ReactDOM from "react-dom"; import { HashRouter, Route, Switch, Redirect } from "react-router-dom"; +import { Provider } from 'react-redux'; +import store from './store/store'; import AuthLayout from "layouts/Auth.js"; import AdminLayout from "layouts/Admin.js"; import RTLLayout from "layouts/RTL.js"; +// Log the initial Redux state +console.log("Initial Redux State:", store.getState()); + ReactDOM.render( - - - - - - - - , + + + + + + + + + + , document.getElementById("root") ); diff --git a/src/routes.js b/src/routes.js index ce9a49159..086fbf0e3 100644 --- a/src/routes.js +++ b/src/routes.js @@ -6,6 +6,7 @@ import RTLPage from "views/Dashboard/RTL"; import Profile from "views/Dashboard/Profile"; import SignIn from "views/Auth/SignIn.js"; import SignUp from "views/Auth/SignUp.js"; +import EventLogging from "views/Dashboard/EventLogging"; import { HomeIcon, @@ -15,6 +16,7 @@ import { DocumentIcon, RocketIcon, SupportIcon, + ActivityIcon, } from "components/Icons/Icons"; var dashRoutes = [ @@ -42,6 +44,14 @@ var dashRoutes = [ component: Billing, layout: "/admin", }, + { + path: "/event-logging", + name: "Event Logging", + rtlName: "سجل الأحداث", + icon: , + component: EventLogging, + layout: "/admin", + }, { path: "/rtl-support-page", name: "RTL", diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 000000000..44dd4d8a0 --- /dev/null +++ b/src/services/api.js @@ -0,0 +1,76 @@ +import axios from 'axios'; + +// Create an axios instance +const api = axios.create({ + baseURL: process.env.REACT_APP_API_URL || 'http://localhost:5000/api', + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, // 10 seconds +}); + +// Add a request interceptor for logging +api.interceptors.request.use( + (config) => { + console.log('API Request:', { + method: config.method, + url: config.url, + data: config.data, + params: config.params, + }); + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('API Request Error:', error); + return Promise.reject(error); + } +); + +// Add a response interceptor for logging +api.interceptors.response.use( + (response) => { + console.log('API Response:', { + status: response.status, + data: response.data, + }); + return response; + }, + (error) => { + console.error('API Response Error:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + }); + if (error.response) { + // Handle specific error cases + switch (error.response.status) { + case 401: + // Handle unauthorized access + localStorage.removeItem('token'); + window.location.href = '/auth/sign-in'; + break; + case 403: + // Handle forbidden access + console.error('Access forbidden'); + break; + case 404: + // Handle not found + console.error('Resource not found'); + break; + case 500: + // Handle server error + console.error('Server error'); + break; + default: + console.error('An error occurred'); + } + } + return Promise.reject(error); + } +); + +export default api; \ No newline at end of file diff --git a/src/services/eventLogService.js b/src/services/eventLogService.js new file mode 100644 index 000000000..0535ac829 --- /dev/null +++ b/src/services/eventLogService.js @@ -0,0 +1,104 @@ +import api from './api'; + +/** + * Get all events with optional filtering + * @param {Object} filters - Filter parameters + * @returns {Promise} - Promise with event data + */ +export const getAllEvents = async (filters = {}) => { + try { + console.log("eventLogService - Original filters:", filters); + + // Filter out empty values + const cleanedFilters = Object.entries(filters).reduce((acc, [key, value]) => { + // Only include non-empty values + if (value !== "" && value !== null && value !== undefined) { + acc[key] = value; + } + return acc; + }, {}); + + console.log("eventLogService - Cleaned filters:", cleanedFilters); + console.log("eventLogService - API call: POST /events with filters:", cleanedFilters); + + // Log the API configuration + console.log("eventLogService - API base URL:", api.defaults.baseURL); + console.log("eventLogService - API headers:", api.defaults.headers); + + const response = await api.post('/events', cleanedFilters); + console.log("eventLogService - Response status:", response.status); + console.log("eventLogService - Response data:", response.data); + return response.data; + } catch (error) { + console.error('eventLogService - Error fetching events:', error); + console.error('eventLogService - Error details:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + config: error.config + }); + throw error; + } +}; + +/** + * Get event statistics for visualization + * @param {Object} filters - Filter parameters + * @returns {Promise} - Promise with event statistics + */ +export const getEventStats = async (filters = {}) => { + try { + console.log("eventLogService - Original filters for stats:", filters); + + // Filter out empty values + const cleanedFilters = Object.entries(filters).reduce((acc, [key, value]) => { + // Only include non-empty values + if (value !== "" && value !== null && value !== undefined) { + acc[key] = value; + } + return acc; + }, {}); + + console.log("eventLogService - Cleaned filters for stats:", cleanedFilters); + console.log("eventLogService - API call: GET /events/stats with filters:", cleanedFilters); + + const response = await api.get('/events/stats', { params: cleanedFilters }); + console.log("eventLogService - Response status:", response.status); + console.log("eventLogService - Response data:", response.data); + return response.data; + } catch (error) { + console.error('eventLogService - Error fetching event statistics:', error); + console.error('eventLogService - Error details:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + config: error.config + }); + throw error; + } +}; + +/** + * Get a specific event by ID + * @param {string} eventId - Event ID + * @returns {Promise} - Promise with event data + */ +export const getEventById = async (eventId) => { + try { + console.log("eventLogService - API call: GET /events/" + eventId); + const response = await api.get(`/events/${eventId}`); + console.log("eventLogService - Response status:", response.status); + console.log("eventLogService - Response data:", response.data); + return response.data; + } catch (error) { + console.error(`eventLogService - Error fetching event with ID ${eventId}:`, error); + console.error('eventLogService - Error details:', { + message: error.message, + status: error.response?.status, + data: error.response?.data, + config: error.config + }); + throw error; + } +}; + diff --git a/src/services/productService.js b/src/services/productService.js new file mode 100644 index 000000000..679b31858 --- /dev/null +++ b/src/services/productService.js @@ -0,0 +1,90 @@ +import api from './api'; + +// Get all products +export const getProducts = async () => { + try { + const response = await api.get('/products'); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.error || 'Failed to fetch products'); + } +}; + +// Get product by ID +export const getProductById = async (id) => { + try { + const response = await api.get(`/products/${id}`); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.error || `Failed to fetch product with ID ${id}`); + } +}; + +// Create a new product +export const createProduct = async (productData) => { + try { + // Transform frontend data to match backend expectations + const transformedData = { + shopify_id: productData.shopify_id || Date.now().toString(), // Generate a temporary ID if not provided + title: productData.title, + description: productData.description || '', + price: parseFloat(productData.price), + sku: productData.sku, + image_url: productData.image || '', // Map 'image' to 'image_url' + }; + + const response = await api.post('/products', transformedData); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.error || 'Failed to create product'); + } +}; + +// Update a product +export const updateProduct = async (id, productData) => { + try { + // Transform frontend data to match backend expectations + const transformedData = { + title: productData.title, + description: productData.description || '', + price: parseFloat(productData.price), + sku: productData.sku, + image_url: productData.image || '', // Map 'image' to 'image_url' + }; + + const response = await api.put(`/products/${id}`, transformedData); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.error || `Failed to update product with ID ${id}`); + } +}; + +// Delete a product +export const deleteProduct = async (product_id) => { + try { + const response = await api.delete(`/products/${product_id}`); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.error || `Failed to delete product with ID ${id}`); + } +}; + +// // Search products +// export const searchProducts = async (query) => { +// try { +// const response = await api.get(`/products/search?q=${encodeURIComponent(query)}`); +// return response.data; +// } catch (error) { +// throw new Error(error.response?.data?.error || 'Failed to search products'); +// } +// }; + +// // Get products by category +// export const getProductsByCategory = async (category) => { +// try { +// const response = await api.get(`/products/category/${encodeURIComponent(category)}`); +// return response.data; +// } catch (error) { +// throw new Error(error.response?.data?.error || `Failed to fetch products in category ${category}`); +// } +// }; \ No newline at end of file diff --git a/src/store/slices/eventLogSlice.js b/src/store/slices/eventLogSlice.js new file mode 100644 index 000000000..621a84a95 --- /dev/null +++ b/src/store/slices/eventLogSlice.js @@ -0,0 +1,39 @@ +import { createSlice } from '@reduxjs/toolkit'; + +// Initial state +const initialState = { + filters: { + time_range: 'day', + // Don't include empty values in the initial state + }, +}; + +// Create the slice +const eventLogSlice = createSlice({ + name: 'eventLog', + initialState, + reducers: { + // Set filters + setFilters: (state, action) => { + // Only include non-empty values + const cleanedFilters = Object.entries(action.payload).reduce((acc, [key, value]) => { + if (value !== "" && value !== null && value !== undefined) { + acc[key] = value; + } + return acc; + }, {}); + + state.filters = cleanedFilters; + }, + // Reset filters to default + resetFilters: (state) => { + state.filters = initialState.filters; + }, + }, +}); + +// Export actions +export const { setFilters, resetFilters } = eventLogSlice.actions; + +// Export reducer +export default eventLogSlice.reducer; \ No newline at end of file diff --git a/src/store/store.js b/src/store/store.js new file mode 100644 index 000000000..feeb57a38 --- /dev/null +++ b/src/store/store.js @@ -0,0 +1,12 @@ +import { configureStore } from '@reduxjs/toolkit'; +import eventLogReducer from './slices/eventLogSlice'; + +// Create the store +const store = configureStore({ + reducer: { + eventLog: eventLogReducer, + // Add other reducers here + }, +}); + +export default store; diff --git a/src/views/Dashboard/EventLogging/components/EventLogChart.js b/src/views/Dashboard/EventLogging/components/EventLogChart.js new file mode 100644 index 000000000..bc3a0e1ba --- /dev/null +++ b/src/views/Dashboard/EventLogging/components/EventLogChart.js @@ -0,0 +1,231 @@ +// Chakra imports +import { + Box, + Text, + useColorModeValue, + Spinner, + Center, + Select, + FormControl, + FormLabel, + Grid, +} from "@chakra-ui/react"; +import React, { useState, useEffect, useRef } from "react"; +import { useSelector } from 'react-redux'; +import { getEventStats } from "services/eventLogService"; +import { Chart, registerables } from 'chart.js'; + +// Register Chart.js components +Chart.register(...registerables); + +function EventLogChart() { + // Chakra color mode + const bgColor = useColorModeValue("white", "navy.700"); + const borderColor = useColorModeValue("gray.200", "gray.600"); + const textColor = useColorModeValue("gray.700", "white"); + + // Redux hooks + const filters = useSelector((state) => state.eventLog.filters); + + // Chart reference + const chartRef = useRef(null); + const chartInstance = useRef(null); + + // State for chart data + const [chartData, setChartData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [groupBy, setGroupBy] = useState(filters.time_range || "day"); + + // Colors for different event types + const eventTypeColors = { + CREATE: "#38A169", // green + UPDATE: "#3182CE", // blue + DELETE: "#E53E3E", // red + }; + + // Fetch chart data when filters or groupBy change + useEffect(() => { + const fetchChartData = async () => { + setLoading(true); + setError(null); + + try { + console.log("Fetching chart data with filters:", filters); + + // Use the same filters but add group_by parameter + const apiFilters = { + ...filters, + group_by: groupBy, + }; + + console.log("API filters for chart:", apiFilters); + + const data = await getEventStats(apiFilters); + console.log("Chart data:", data); + + // Transform data for chart + const transformedData = data.timestamps.map((timestamp) => { + const dataPoint = { timestamp }; + + // Add count for each event type + data.event_types.forEach((eventType) => { + dataPoint[eventType] = data.data[eventType][timestamp] || 0; + }); + + return dataPoint; + }); + + setChartData(transformedData); + } catch (err) { + console.error("Error fetching chart data:", err); + setError("Failed to load chart data. Please try again later."); + } finally { + setLoading(false); + } + }; + + fetchChartData(); + }, [filters, groupBy]); + + // Format timestamp for x-axis + const formatTimestamp = (timestamp) => { + if (!timestamp) return ""; + + const date = new Date(timestamp); + + switch (groupBy) { + case "day": + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + case "week": + return `Week ${Math.ceil(date.getDate() / 7)}`; + case "month": + return date.toLocaleDateString(undefined, { month: "short" }); + default: + return timestamp; + } + }; + + // Handle group by change + const handleGroupByChange = (e) => { + setGroupBy(e.target.value); + }; + + // Initialize or update chart when data changes + useEffect(() => { + if (loading || error || chartData.length === 0) return; + + // Destroy previous chart instance if it exists + if (chartInstance.current) { + chartInstance.current.destroy(); + } + + // Prepare data for Chart.js + const labels = chartData.map(item => formatTimestamp(item.timestamp)); + + // Get unique event types (excluding timestamp) + const eventTypes = Object.keys(chartData[0]).filter(key => key !== 'timestamp'); + + // Create datasets for each event type + const datasets = eventTypes.map(eventType => ({ + label: eventType, + data: chartData.map(item => item[eventType]), + borderColor: eventTypeColors[eventType] || "#718096", + backgroundColor: eventTypeColors[eventType] || "#718096", + tension: 0.4, + fill: false, + })); + + // Create new chart + const ctx = chartRef.current.getContext('2d'); + chartInstance.current = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + mode: 'index', + intersect: false, + }, + }, + scales: { + x: { + grid: { + display: true, + drawBorder: false, + }, + }, + y: { + beginAtZero: true, + grid: { + display: true, + drawBorder: false, + }, + }, + }, + }, + }); + + // Cleanup function + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + }, [chartData, loading, error, groupBy]); + + return ( + + + + Event Statistics + + + + Group By + + + + + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : chartData.length === 0 ? ( +
+ No data available for the selected filters. +
+ ) : ( + + + + )} + + ); +} + +export default EventLogChart; + diff --git a/src/views/Dashboard/EventLogging/components/EventLogFilters.js b/src/views/Dashboard/EventLogging/components/EventLogFilters.js new file mode 100644 index 000000000..7f283752a --- /dev/null +++ b/src/views/Dashboard/EventLogging/components/EventLogFilters.js @@ -0,0 +1,150 @@ +// Chakra imports +import { + Box, + Button, + FormControl, + FormLabel, + Input, + Select, + Stack, + useColorModeValue, +} from "@chakra-ui/react"; +import React, { useState, useEffect } from "react"; +import { useDispatch, useSelector } from 'react-redux'; +import { setFilters, resetFilters } from 'store/slices/eventLogSlice'; + +function EventLogFilters() { + // Chakra color mode + const bgColor = useColorModeValue("white", "navy.700"); + const borderColor = useColorModeValue("gray.200", "gray.600"); + + // Redux hooks + const dispatch = useDispatch(); + const reduxFilters = useSelector((state) => state.eventLog.filters); + + // Local state for form values + const [formValues, setFormValues] = useState({ + timeRange: reduxFilters.time_range, + eventType: reduxFilters.event_type, + userId: reduxFilters.user_id, + }); + + // Update local state when Redux state changes + useEffect(() => { + setFormValues({ + timeRange: reduxFilters.time_range, + eventType: reduxFilters.event_type, + userId: reduxFilters.user_id, + }); + }, [reduxFilters]); + + // Handle input change + const handleChange = (e) => { + const { name, value } = e.target; + setFormValues((prev) => ({ + ...prev, + [name]: value, + })); + }; + + // Handle form submission + const handleSubmit = (e) => { + e.preventDefault(); + console.log("Submitting filters:", formValues); + + // Convert frontend filters to API filters + const apiFilters = { + time_range: formValues.timeRange, + }; + + // Only include non-empty values + if (formValues.eventType) { + apiFilters.event_type = formValues.eventType; + } + + if (formValues.userId) { + apiFilters.user_id = formValues.userId; + } + + console.log("API filters:", apiFilters); + + // Dispatch action to update Redux state + dispatch(setFilters(apiFilters)); + }; + + // Handle form reset + const handleReset = () => { + setFormValues({ + timeRange: "day", + eventType: "", + userId: "", + }); + + // Dispatch action to reset Redux state + dispatch(resetFilters()); + }; + + return ( + +
+ + + Time Range + + + + + Event Type + + + + + User ID + + + + + + + + +
+
+ ); +} + +export default EventLogFilters; + diff --git a/src/views/Dashboard/EventLogging/components/EventLogTable.js b/src/views/Dashboard/EventLogging/components/EventLogTable.js new file mode 100644 index 000000000..cbacdff20 --- /dev/null +++ b/src/views/Dashboard/EventLogging/components/EventLogTable.js @@ -0,0 +1,147 @@ +// Chakra imports +import { + Box, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Text, + useColorModeValue, + Spinner, + Center, + Badge, + TableContainer, +} from "@chakra-ui/react"; +import React, { useState, useEffect } from "react"; +import { useSelector } from 'react-redux'; +import { getAllEvents } from "services/eventLogService"; + +function EventLogTable() { + // Chakra color mode + const bgColor = useColorModeValue("white", "navy.700"); + const borderColor = useColorModeValue("gray.200", "gray.600"); + const textColor = useColorModeValue("gray.700", "white"); + + // Redux hooks + const filters = useSelector((state) => state.eventLog.filters); + console.log("EventLogTable - Redux filters:", filters); + + // State for events + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Log component mount + useEffect(() => { + console.log("EventLogTable - Component mounted"); + return () => { + console.log("EventLogTable - Component unmounted"); + }; + }, []); + + // Fetch events when filters change + useEffect(() => { + console.log("EventLogTable - Filters changed, fetching events"); + const fetchEvents = async () => { + setLoading(true); + setError(null); + + try { + console.log("EventLogTable - Fetching events with filters:", filters); + const data = await getAllEvents(filters); + console.log("EventLogTable - Events data received:", data); + setEvents(data); + } catch (err) { + console.error("EventLogTable - Error fetching events:", err); + setError("Failed to load event logs. Please try again later."); + } finally { + setLoading(false); + } + }; + + fetchEvents(); + }, [filters]); + + // Format timestamp + const formatTimestamp = (timestamp) => { + if (!timestamp) return "N/A"; + + const date = new Date(timestamp); + return date.toLocaleString(); + }; + + // Get badge color based on event type + const getEventTypeColor = (eventType) => { + switch (eventType?.toLowerCase()) { + case "create": + return "green"; + case "update": + return "blue"; + case "delete": + return "red"; + default: + return "gray"; + } + }; + + return ( + + + Event Logs + + + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : events.length === 0 ? ( +
+ No events found matching the selected filters. +
+ ) : ( + + + + + + + + + + + + {events.map((event) => ( + + + + + + + ))} + +
TimestampEvent TypeUser IDProduct ID
{formatTimestamp(event.timestamp)} + + {event.event_type} + + {event.user_id || "N/A"}{event.product_id || "N/A"}
+
+ )} +
+ ); +} + +export default EventLogTable; + diff --git a/src/views/Dashboard/EventLogging/index.js b/src/views/Dashboard/EventLogging/index.js new file mode 100644 index 000000000..f578540bd --- /dev/null +++ b/src/views/Dashboard/EventLogging/index.js @@ -0,0 +1,40 @@ +// Chakra imports +import { + Box, + Grid, + Container, + useColorModeValue, +} from "@chakra-ui/react"; +import React from "react"; +// Custom components +import EventLogTable from "./components/EventLogTable"; +import EventLogChart from "./components/EventLogChart"; +import EventLogFilters from "./components/EventLogFilters"; + +function EventLogging() { + // Chakra color mode + const bgColor = useColorModeValue("gray.50", "navy.900"); + + return ( + + + + + + + + + + + + + + + ); +} + +export default EventLogging; \ No newline at end of file diff --git a/src/views/Dashboard/Tables/components/Products.js b/src/views/Dashboard/Tables/components/Products.js new file mode 100644 index 000000000..78c28a8d2 --- /dev/null +++ b/src/views/Dashboard/Tables/components/Products.js @@ -0,0 +1,436 @@ +// Chakra imports +import { + Table, + Tbody, + Text, + Th, + Thead, + Tr, + Td, + useColorModeValue, + Button, + Flex, + Input, + InputGroup, + InputLeftElement, + Select, + Box, + HStack, + Spinner, + Center, + useToast, + AlertDialog, + AlertDialogBody, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogContent, + AlertDialogOverlay, +} from "@chakra-ui/react"; +// Custom components +import Card from "components/Card/Card.js"; +import CardBody from "components/Card/CardBody.js"; +import CardHeader from "components/Card/CardHeader.js"; +import ProductTableRow from "components/Tables/ProductTableRow"; +import ProductViewModal from "components/Products/ProductViewModal"; +import ProductCreateModal from "components/Products/ProductCreateModal"; +import ProductEditModal from "components/Products/ProductEditModal"; +import React, { useState, useEffect, useRef } from "react"; +import { FaPlus, FaSearch } from "react-icons/fa"; +import { getProducts, createProduct, updateProduct, deleteProduct } from "services/productService"; + +const Products = ({ title, captions, data: initialData }) => { + const textColor = useColorModeValue("gray.700", "white"); + const [products, setProducts] = useState([]); + const [filteredProducts, setFilteredProducts] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [sortField, setSortField] = useState("title"); + const [sortDirection, setSortDirection] = useState("asc"); + const [selectedProduct, setSelectedProduct] = useState(null); + const [isViewModalOpen, setIsViewModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [productToDelete, setProductToDelete] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const cancelRef = useRef(); + const toast = useToast(); + + // Fetch products on component mount + useEffect(() => { + fetchProducts(); + }, []); + + + const ResponsiveDebug = () => { + const [breakpoint, setBreakpoint] = useState(''); + + useEffect(() => { + const updateBreakpoint = () => { + const width = window.innerWidth; + if (width < 480) setBreakpoint('xs'); + else if (width < 768) setBreakpoint('sm'); + else if (width < 992) setBreakpoint('md'); + else if (width < 1280) setBreakpoint('lg'); + else setBreakpoint('xl'); + }; + + updateBreakpoint(); + window.addEventListener('resize', updateBreakpoint); + return () => window.removeEventListener('resize', updateBreakpoint); + }, []); + + return ( + + {breakpoint} + + ); + }; + + const fetchProducts = async () => { + try { + setIsLoading(true); + setError(null); + const data = await getProducts(); + console.log(data) + setProducts(data); + setFilteredProducts(data); + } catch (err) { + setError("Failed to load products. Please try again later."); + console.error("Error fetching products:", err); + } finally { + setIsLoading(false); + } + }; + + // Handle search + useEffect(() => { + const filtered = products.filter(product => + product.title.toLowerCase().includes(searchTerm.toLowerCase()) || + product.sku.toLowerCase().includes(searchTerm.toLowerCase()) + ); + setFilteredProducts(filtered); + }, [searchTerm, products]); + + // Handle sorting + useEffect(() => { + const sorted = [...filteredProducts].sort((a, b) => { + if (sortField === "price") { + return sortDirection === "asc" + ? parseFloat(a[sortField]) - parseFloat(b[sortField]) + : parseFloat(b[sortField]) - parseFloat(a[sortField]); + } + + if (a[sortField] < b[sortField]) return sortDirection === "asc" ? -1 : 1; + if (a[sortField] > b[sortField]) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + setFilteredProducts(sorted); + }, [sortField, sortDirection]); + + const handleSearch = (e) => { + setSearchTerm(e.target.value); + }; + + const handleSort = (field) => { + if (field === sortField) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + const handleViewProduct = (product) => { + setSelectedProduct(product); + setIsViewModalOpen(true); + }; + + const handleCreateProduct = async (newProduct) => { + try { + setIsLoading(true); + const createdProduct = await createProduct(newProduct); + setProducts([...products, createdProduct]); + toast({ + title: "Product created", + description: `${createdProduct.title} has been created successfully.`, + status: "success", + duration: 3000, + isClosable: true, + }); + } catch (err) { + toast({ + title: "Error creating product", + description: err.message || "An error occurred while creating the product.", + status: "error", + duration: 5000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + + const handleEditProduct = (product) => { + setSelectedProduct(product); + setIsEditModalOpen(true); + }; + + const handleUpdateProduct = async (productId, updatedData) => { + try { + setIsLoading(true); + const updatedProduct = await updateProduct(productId, updatedData); + + // Update the products list with the edited product + setProducts(products.map(p => p.shopify_id === productId ? updatedProduct : p)); + + toast({ + title: "Product updated", + description: `${updatedProduct.title} has been updated successfully.`, + status: "success", + duration: 3000, + isClosable: true, + }); + } catch (err) { + toast({ + title: "Error updating product", + description: err.message || "An error occurred while updating the product.", + status: "error", + duration: 5000, + isClosable: true, + }); + throw err; // Re-throw to let the modal handle the error + } finally { + setIsLoading(false); + } + }; + + const handleDeleteClick = (product) => { + setProductToDelete(product); + setIsDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!productToDelete) return; + + try { + setIsLoading(true); + await deleteProduct(productToDelete.shopify_id); + setProducts(products.filter(p => p.shopify_id !== productToDelete.shopify_id)); + toast({ + title: "Product deleted", + description: `${productToDelete.title} has been deleted successfully.`, + status: "success", + duration: 3000, + isClosable: true, + }); + } catch (err) { + toast({ + title: "Error deleting product", + description: err.message || "An error occurred while deleting the product.", + status: "error", + duration: 5000, + isClosable: true, + }); + } finally { + setIsLoading(false); + setIsDeleteDialogOpen(false); + setProductToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setIsDeleteDialogOpen(false); + setProductToDelete(null); + }; + + return ( + + +
+ {title} +
+ + + + +
+ +
+ + + + + + + + + + + + + + + {error && ( + + {error} + + )} + + {isLoading ? ( +
+ +
+ ) : ( + + + + + {captions.map((caption, idx) => { + const field = caption.toLowerCase(); + const isSortable = ["title", "sku", "price"].includes(field); + return ( + + ); + })} + + + + {filteredProducts.length > 0 ? ( + filteredProducts.map((row) => { + // console.log(row); + return ( + handleViewProduct(row)} + onEdit={() => handleEditProduct(row)} + onDelete={() => handleDeleteClick(row)} + /> + ); + }) + ) : ( + + + + )} + +
handleSort(field) : undefined} + > + {caption} + {isSortable && sortField === field && ( + {sortDirection === "asc" ? "↑" : "↓"} + )} +
+ No products found +
+
+ )} +
+ + + {/* This CardBody is kept empty for future additions */} + + + {/* View Modal */} + setIsViewModalOpen(false)} + product={selectedProduct} + /> + + {/* Create Modal */} + setIsCreateModalOpen(false)} + onCreateProduct={handleCreateProduct} + /> + + {/* Edit Modal */} + setIsEditModalOpen(false)} + onEditProduct={handleUpdateProduct} + product={selectedProduct} + /> + + {/* Delete Confirmation Dialog */} + + + + + Delete Product + + + + Are you sure you want to delete {productToDelete?.title}? This action cannot be undone. + + + + + + + + + +
+ ); +}; + + + +export default Products; \ No newline at end of file diff --git a/src/views/Dashboard/Tables/index.js b/src/views/Dashboard/Tables/index.js index a686c6254..f4c4cdee7 100644 --- a/src/views/Dashboard/Tables/index.js +++ b/src/views/Dashboard/Tables/index.js @@ -3,11 +3,18 @@ import { Flex } from "@chakra-ui/react"; import React from "react"; import Authors from "./components/Authors"; import Projects from "./components/Projects"; +import Products from "./components/Products"; import { tablesTableData, dashboardTableData } from "variables/general"; + function Tables() { return ( +