diff --git a/client/src/App.jsx b/client/src/App.jsx index ccda6c8..2fffdea 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -10,6 +10,7 @@ import { Admin } from "./components/admin/Admin"; import { CatchAll } from "./components/CatchAll"; import { Dashboard } from "./components/dashboard/Dashboard"; import { Playground } from "./components/playground/Playground"; +import { Notifications } from "./components/notifications/Notifications"; import { Login } from "./components/login/Login"; import { ProtectedRoute } from "./components/ProtectedRoute"; import { Signup } from "./components/signup/Signup"; @@ -40,6 +41,10 @@ const App = () => { path="/playground" element={} /> + } />} + /> } />} diff --git a/client/src/components/notifications/CalendarIcon.jsx b/client/src/components/notifications/CalendarIcon.jsx new file mode 100644 index 0000000..de23f33 --- /dev/null +++ b/client/src/components/notifications/CalendarIcon.jsx @@ -0,0 +1,14 @@ +export const CalendarIcon = () => ( + + + +); diff --git a/client/src/components/notifications/Counter.jsx b/client/src/components/notifications/Counter.jsx new file mode 100644 index 0000000..1614d61 --- /dev/null +++ b/client/src/components/notifications/Counter.jsx @@ -0,0 +1,29 @@ +export const CounterComponent = ({ count }) => { + return ( +
+

+ {count} +

+
+ ); +}; diff --git a/client/src/components/notifications/FilterButton.jsx b/client/src/components/notifications/FilterButton.jsx new file mode 100644 index 0000000..e846d63 --- /dev/null +++ b/client/src/components/notifications/FilterButton.jsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + Button, + HStack, + Popover, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, + Text, + useToast, +} from "@chakra-ui/react"; + +import { CalendarIcon } from "./CalendarIcon"; +import styles from "./FilterButton.module.css"; +import { FilterIcon } from "./FilterIcon"; + +export const FilterButton = ({ setFilterType, currentFilter }) => { + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const toast = useToast(); + const [type, setType] = useState("all"); + + // Watch for changes in both dates + useEffect(() => { + console.log(startDate, endDate); + if (startDate.substring(0,1) === "2" && endDate.substring(0,1) === "2") { // checking if the date is filled out completely + const start = new Date(startDate); + const end = new Date(endDate); + + console.log(start, end); + if (start > end) { + toast({ + title: "Invalid Date Range", + description: "Start date must be before end date", + status: "error", + duration: 3000, + isClosable: true, + }); + return; + } + + // Update filter with new dates + setFilterType(prev => ({ + ...prev, + type: type, + startDate: startDate, // The date input already returns YYYY-MM-DD format + endDate: endDate + })); + } + }, [startDate, endDate, type, toast]); + + const handleFilterSelect = (type) => { + setType(type); + setFilterType(prev => ({ + ...prev, + type, + })); + }; + + return ( + + + + + + + + + + + Date Range + + +
+ setStartDate(e.target.value)} + className={styles.dateInput} + /> + to + setEndDate(e.target.value)} + className={styles.dateInput} + /> +
+ + Type + +
+ + + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/components/notifications/FilterButton.module.css b/client/src/components/notifications/FilterButton.module.css new file mode 100644 index 0000000..7e50799 --- /dev/null +++ b/client/src/components/notifications/FilterButton.module.css @@ -0,0 +1,149 @@ +/* FilterButton.module.css */ +.modalContent { + display: inline-flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + gap: 16px; + border-radius: 15px; + border: 1px solid var(--medium-light-grey, #D2D2D2); + background: var(--white, #FFF); + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + width: 200px; +} + +.dateRangeHeader { + color: var(--medium-grey, #767778); + font-family: "Inter", sans-serif; + font-size: 18px; + font-style: normal; + font-weight: 600; + line-height: normal; + margin-bottom: 16px; + +} + +.modalBody { + padding: 0; +} + +.dateInputContainer { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + width: 280px; +} + +.dateInput { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 5px; + border: 1px solid var(--medium-light-grey, #d2d2d2); + background-color: #F6F6F6; + padding: 8px; + font-family: "Inter", sans-serif; + font-size: 13px; + color: #767778; + width: 110px; + cursor: pointer; + overflow: hidden; +} + +/* Make the calendar icon transparent but keep it clickable */ +.dateInput::-webkit-calendar-picker-indicator { + opacity: 0; + position: absolute; + width: 100%; + height: 100%; + cursor: pointer; + margin: 0; + padding: 0; + visibility: hidden; +} + + +/* Remove default styling */ +.dateInput::-webkit-datetime-edit { + padding: 0; +} + +.dateInput::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +.dateInput::-webkit-datetime-edit-text, +.dateInput::-webkit-datetime-edit-month-field, +.dateInput::-webkit-datetime-edit-day-field, +.dateInput::-webkit-datetime-edit-year-field { + color: #767778; +} + +/* When no date is selected (placeholder state) */ +.dateInput:invalid { + color: #767778; +} + + +.dateInput::placeholder{ + color: var(--medium-grey, #767778); + font-family: "Inter", sans-serif; + font-size: 13px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 125% */ +} + +.toText { + color: var(--medium-grey, #767778); + font-family: "Inter", sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.typeHeader { + color: var(--medium-grey, #767778); + font-family: "Inter", sans-serif; + font-size: 18px; + font-style: normal; + font-weight: 600; + line-height: normal; + margin-bottom: 16px; +} + +.filterButtonGroup { + display: flex; + align-items: flex-start; + gap: 12px; + align-self: stretch; +} + +.filterButton { + border: 1px solid #d2d2d2 !important; + border-radius: 15px !important; + padding: 0px 15px !important; + background: white !important; + color: #767778 !important; + transition: all 0.2s !important; + color: var(--medium-grey, #767778); + font-family: "Inter", sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +.filterButton:hover { + background: #f5f5f5 !important; +} + +.filterButton.active { + color: var(--indigo, #4E4AE7) !important; + border-radius: 30px !important; + border: 1px solid var(--indigo, #4E4AE7) !important; + background: var(--light-indigo, #EDEDFD) !important; +} diff --git a/client/src/components/notifications/FilterIcon.jsx b/client/src/components/notifications/FilterIcon.jsx new file mode 100644 index 0000000..02e1a0b --- /dev/null +++ b/client/src/components/notifications/FilterIcon.jsx @@ -0,0 +1,18 @@ +export const FilterIcon = () => ( + + + + ); /** simplified syntax */ \ No newline at end of file diff --git a/client/src/components/notifications/Notifications.jsx b/client/src/components/notifications/Notifications.jsx new file mode 100644 index 0000000..9c16c25 --- /dev/null +++ b/client/src/components/notifications/Notifications.jsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; + +import { useBackendContext } from "../../contexts/hooks/useBackendContext"; +import Navbar from "../Navbar"; +import NotificationsComponents from "./NotificationsComponents"; +import { CounterComponent } from "./Counter"; +import { FilterButton } from "./FilterButton"; +import styles from "./Notifications.module.css"; +import { formatDistanceToNow } from 'date-fns'; + +export const Notifications = () => { + const { backend } = useBackendContext(); + + const [notifications, setNotifications] = useState([]); + const [filterType, setFilterType] = useState({ + type: "all", + startDate: null, + endDate: null, + }); // all, overdue, neardue + + useEffect(() => { + const fetchNotifs = async () => { + try { + const today = new Date(); + let endpoints = []; + let queryParams = ""; + + // If we have dates, add them as query params regardless of filter type + if (filterType.startDate && filterType.endDate) { + queryParams = `?startDate=${filterType.startDate}&endDate=${filterType.endDate}`; + } + + // Only modify the endpoint if we're filtering by type AND it's not "all" + if (filterType.type === "all") { + endpoints = [`/invoices/overdue${queryParams}`, `/invoices/neardue${queryParams}`]; + } else { + endpoints = [`/invoices/${filterType.type}${queryParams}`]; + } + + const responses = await Promise.all( + endpoints.map(endpoint => backend.get(endpoint)) + ); + + const notifsData = responses.flatMap(res => res.data); + + // Fetch additional data for each invoice (total, paid, event name) + const enrichedInvoices = await Promise.all( + notifsData.map(async (invoice) => { + try { + const [totalRes, paidRes, eventRes] = await Promise.all([ + backend.get(`/invoices/total/${invoice.id}`), + backend.get(`/invoices/paid/${invoice.id}`), + backend.get(`/events/${invoice.eventId}`), + ]); + + const endDate = new Date(invoice.endDate); + const dueTime = formatDistanceToNow(endDate, { addSuffix: true }); + let payStatus = ""; + if (endDate < today) { + payStatus = "overdue"; + } else { + payStatus = "due in one week"; + } + + return { + ...invoice, + eventName: eventRes.data[0]?.name || "Unknown Event", + total: totalRes.data.total, + paid: paidRes.data.paid, + payStatus, + dueTime + }; + } catch (err) { + console.error(`Failed to fetch additional data for invoice ID: ${invoice.id}`, err); + return { + ...invoice, + eventName: "Unknown Event", + total: 0, + paid: 0, + payStatus: "Unknown", + dueTime: "Unknown" + }; + } + }) + ); + + setNotifications(enrichedInvoices); + } catch (err) { + console.error("Failed to fetch invoices", err); + } + }; + fetchNotifs(); + }, [filterType, backend]); + + + return ( + +
+
+
+

Invoice Notifications

+ +
+ + +
+ +
+
+ ); +}; diff --git a/client/src/components/notifications/Notifications.module.css b/client/src/components/notifications/Notifications.module.css new file mode 100644 index 0000000..8fc7c1d --- /dev/null +++ b/client/src/components/notifications/Notifications.module.css @@ -0,0 +1,15 @@ +.title { + color: #4E4AE7; + font-family: "Inter", sans-serif; + font-size: 30px; + font-style: normal; + font-weight: 600; + line-height: normal; + margin-right: 31px; /** a bit arbitrary */ +} + +.titleContainer { + display: flex; + flex-direction: row; + margin-bottom: 30px; +} \ No newline at end of file diff --git a/client/src/components/notifications/NotificationsComponents.jsx b/client/src/components/notifications/NotificationsComponents.jsx new file mode 100644 index 0000000..b47d8ed --- /dev/null +++ b/client/src/components/notifications/NotificationsComponents.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { + Box, + Flex, + Text +} from '@chakra-ui/react'; +import { format } from "date-fns"; +import { MdEmail } from 'react-icons/md'; + + +const NotificationsComponents = ({ notifications }) => { + return ( + + {notifications.map((item, index) => ( + + + + + + + + {item.eventName} is {item.payStatus} + + {item.eventName} | Due: {format(new Date(item.endDate), "MMM d")} | Total: ${item.total} | Paid: ${item.paid} + + + + {item.dueTime} + + ))} + + ); +}; + +export default NotificationsComponents; \ No newline at end of file diff --git a/server/routes/invoices.js b/server/routes/invoices.js index d06b57f..fc84db8 100644 --- a/server/routes/invoices.js +++ b/server/routes/invoices.js @@ -8,11 +8,90 @@ invoicesRouter.use(express.json()); // Get all invoices invoicesRouter.get("/", async (req, res) => { try { - const users = await db.query(`SELECT * FROM invoices`); + const { startDate, endDate } = req.query; + console.log("Query params:", startDate, endDate); + + if (startDate && endDate) { + const invoices = await db.any( + `SELECT * FROM invoices WHERE start_date >= $1::date AND end_date <= $2::date`, + [startDate, endDate] + ); + res.status(200).json(keysToCamel(invoices)); + } else { + const invoices = await db.any(`SELECT * FROM invoices ORDER BY id ASC`); + + if (invoices.length === 0) { + return res.status(404).json({ error: "No invoices found." }); + } + + res.status(200).json(keysToCamel(invoices)); + } + } catch (err) { + res.status(500).send(err.message); + } +}); + +// Get all overdue invoices with optional date range filtering +invoicesRouter.get("/overdue", async (req, res) => { + try { + const { startDate, endDate } = req.query; + + // Base query for overdue invoices + let query = ` + SELECT * FROM invoices + WHERE is_sent = false + AND payment_status IN ('partial', 'none') + AND end_date < CURRENT_DATE`; + + const params = []; + + // Add date range filtering if both dates are provided + if (startDate && endDate) { + query = ` + SELECT * FROM invoices + WHERE is_sent = false + AND payment_status IN ('partial', 'none') + AND end_date < CURRENT_DATE + AND start_date >= $1 + AND end_date <= $2`; + params.push(startDate, endDate); + } - res.status(200).json(keysToCamel(users)); + const overdueInvoices = await db.query(query, params); + res.status(200).json(keysToCamel(overdueInvoices)); } catch (err) { - res.status(400).send(err.message); + res.status(500).send(err.message); + } +}); + +// Get all invoices due within the next week with optional date range filtering +invoicesRouter.get("/neardue", async (req, res) => { + try { + const { startDate, endDate } = req.query; + + // Base query for neardue invoices + let query = ` + SELECT * FROM invoices + WHERE is_sent = false + AND end_date BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '7 days')`; + + const params = []; + + // Add date range filtering if both dates are provided + if (startDate && endDate) { + query = ` + SELECT * FROM invoices + WHERE is_sent = false + AND end_date BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '7 days') + AND start_date >= $1 + AND end_date <= $2`; + params.push(startDate, endDate); + } + + const nearDueInvoices = await db.query(query, params); + res.status(200).json(keysToCamel(nearDueInvoices)); + } catch (err) { + res.status(500).send(err.message); } });