diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java b/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java index bc0ed958..9326f2a8 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/FeedbackController.java @@ -13,6 +13,7 @@ import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import com.rit.canteen.sales.service.SystemNotificationService; import java.util.*; @@ -29,6 +30,9 @@ public class FeedbackController { @Autowired private ItemRatingRepository itemRatingRepository; + @Autowired + private SystemNotificationService notificationService; + @GetMapping public Page getAllFeedback( @RequestParam(defaultValue = "0") int page, @@ -188,6 +192,14 @@ public class FeedbackController { order.setHasFeedback(true); orderRepository.save(order); + // Notify Admins + notificationService.createNotification( + "New Feedback Received", + "A new feedback has been submitted by " + (feedback.getUserName() != null ? feedback.getUserName() : "a customer") + " for Order #" + order.getDisplayOrderId(), + "FEEDBACK", + "/feedback" + ); + return ResponseEntity.ok(savedFeedback); } } diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java b/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java index b9f510cb..4180e96e 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/ProductController.java @@ -6,6 +6,7 @@ import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.rit.canteen.sales.service.SystemNotificationService; import java.util.List; @@ -22,6 +23,9 @@ public class ProductController { @Autowired private StockUpdateController stockUpdateController; + @Autowired + private SystemNotificationService notificationService; + @GetMapping public org.springframework.data.domain.Page getAllProducts( @RequestParam(required = false) String search, @@ -53,6 +57,16 @@ public class ProductController { public Product createProduct(@Valid @RequestBody Product product) { Product savedProduct = productRepository.save(product); updateStallAssociations(savedProduct, product.getStalls()); + + if (savedProduct.isDraft()) { + notificationService.createNotification( + "Draft Product Created", + "A new product draft '" + savedProduct.getName() + "' has been created and requires review before publishing.", + "PRODUCT", + "/inventory/products" + ); + } + return productRepository.findById(savedProduct.getId()).orElse(savedProduct); } @@ -249,6 +263,13 @@ public class ProductController { Product updated = productRepository.save(product); updateStallAssociations(updated, productDetails.getStalls()); + notificationService.createNotification( + "Product Published", + "Product '" + productDetails.getName() + "' is now live in the inventory with ID: " + finalProductId, + "PRODUCT", + "/inventory/products" + ); + return ResponseEntity.ok(productRepository.findById(updated.getId()).orElse(updated)); } }) diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/PurchaseController.java b/backend/src/main/java/com/rit/canteen/sales/controller/PurchaseController.java index ee9dd959..5c5406b9 100644 --- a/backend/src/main/java/com/rit/canteen/sales/controller/PurchaseController.java +++ b/backend/src/main/java/com/rit/canteen/sales/controller/PurchaseController.java @@ -10,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.rit.canteen.sales.service.SystemNotificationService; + import java.util.List; import java.util.Map; @@ -22,6 +24,9 @@ public class PurchaseController { @Autowired private PurchaseService purchaseService; + @Autowired + private SystemNotificationService notificationService; + @GetMapping("/orders") public List getAllOrders() { return purchaseService.getAllOrders(); @@ -30,7 +35,17 @@ public class PurchaseController { @PostMapping("/orders") public PurchaseOrder createOrder(@RequestBody PurchaseOrder order) { logger.info("Received request to create/update order: {}", order.getPurchaseId()); - return purchaseService.createOrder(order); + PurchaseOrder saved = purchaseService.createOrder(order); + + // Notify Admins + notificationService.createNotification( + "Procurement Update", + "A new purchase order (" + saved.getPurchaseId() + ") has been created/updated for vendor: " + (saved.getVendor() != null ? saved.getVendor().getName() : "Unknown"), + "PURCHASE", + "/purchases/orders" + ); + + return saved; } @GetMapping("/orders/{id}/history") diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java b/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java new file mode 100644 index 00000000..2ebfd4ae --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/controller/SystemNotificationController.java @@ -0,0 +1,37 @@ +package com.rit.canteen.sales.controller; + +import com.rit.canteen.sales.model.SystemNotification; +import com.rit.canteen.sales.service.SystemNotificationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/notifications") +@CrossOrigin(origins = "*") +public class SystemNotificationController { + + @Autowired + private SystemNotificationService notificationService; + + @GetMapping + public List getUnread() { + return notificationService.getUnreadNotifications(); + } + + @GetMapping("/all") + public List getAll() { + return notificationService.getAllNotifications(); + } + + @PostMapping("/mark-read/{id}") + public void markRead(@PathVariable Long id) { + notificationService.markAsRead(id); + } + + @PostMapping("/mark-all-read") + public void markAllRead() { + notificationService.markAllAsRead(); + } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/model/SystemNotification.java b/backend/src/main/java/com/rit/canteen/sales/model/SystemNotification.java new file mode 100644 index 00000000..0a85036d --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/model/SystemNotification.java @@ -0,0 +1,68 @@ +package com.rit.canteen.sales.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "system_notifications") +public class SystemNotification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, length = 1000) + private String message; + + @Column(nullable = false) + private String type; // FEEDBACK, PURCHASE, PRODUCT, COUPON + + @Column(nullable = false) + private boolean isRead = false; + + @Column(nullable = true) + private String link; // URL to navigate to + + @Column(nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + public SystemNotification() {} + + public SystemNotification(String title, String message, String type, String link) { + this.title = title; + this.message = message; + this.type = type; + this.link = link; + this.createdAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public boolean isRead() { return isRead; } + public void setRead(boolean read) { isRead = read; } + + public String getLink() { return link; } + public void setLink(String link) { this.link = link; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java b/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java index 8a184696..c26082a6 100644 --- a/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java +++ b/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java @@ -5,7 +5,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.List; +import java.time.LocalDateTime; + @Repository public interface CouponRepository extends JpaRepository { Optional findByCode(String code); + List findByExpiryDateBeforeAndIsActiveTrue(LocalDateTime threshold); } diff --git a/backend/src/main/java/com/rit/canteen/sales/repository/SystemNotificationRepository.java b/backend/src/main/java/com/rit/canteen/sales/repository/SystemNotificationRepository.java new file mode 100644 index 00000000..1e3f9ae2 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/repository/SystemNotificationRepository.java @@ -0,0 +1,12 @@ +package com.rit.canteen.sales.repository; + +import com.rit.canteen.sales.model.SystemNotification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface SystemNotificationRepository extends JpaRepository { + List findByIsReadFalseOrderByCreatedAtDesc(); + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/rit/canteen/sales/service/SystemNotificationService.java b/backend/src/main/java/com/rit/canteen/sales/service/SystemNotificationService.java new file mode 100644 index 00000000..967b9b41 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/service/SystemNotificationService.java @@ -0,0 +1,78 @@ +package com.rit.canteen.sales.service; + +import com.rit.canteen.sales.model.CouponCode; +import com.rit.canteen.sales.model.SystemNotification; +import com.rit.canteen.sales.repository.CouponRepository; +import com.rit.canteen.sales.repository.SystemNotificationRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class SystemNotificationService { + + @Autowired + private SystemNotificationRepository notificationRepository; + + @Autowired + private CouponRepository couponRepository; + + @Transactional + public void createNotification(String title, String message, String type, String link) { + SystemNotification notification = new SystemNotification(title, message, type, link); + notificationRepository.save(notification); + } + + public List getUnreadNotifications() { + return notificationRepository.findByIsReadFalseOrderByCreatedAtDesc(); + } + + public List getAllNotifications() { + return notificationRepository.findAllByOrderByCreatedAtDesc(); + } + + @Transactional + public void markAsRead(Long id) { + notificationRepository.findById(id).ifPresent(n -> { + n.setRead(true); + notificationRepository.save(n); + }); + } + + @Transactional + public void markAllAsRead() { + List unread = notificationRepository.findByIsReadFalseOrderByCreatedAtDesc(); + for (SystemNotification n : unread) { + n.setRead(true); + } + notificationRepository.saveAll(unread); + } + + // Triggered every 6 hours to check for expiring coupons + @Scheduled(fixedRate = 21600000) + @Transactional + public void checkExpiringCoupons() { + LocalDateTime threshold = LocalDateTime.now().plusHours(48); + List expiringSoon = couponRepository.findByExpiryDateBeforeAndIsActiveTrue(threshold); + + for (CouponCode coupon : expiringSoon) { + String title = "Coupon Expiring Soon: " + coupon.getCode(); + // Avoid duplicate notifications for the same coupon within the last 24 hours + boolean exists = notificationRepository.findAllByOrderByCreatedAtDesc().stream() + .anyMatch(n -> n.getTitle().equals(title) && n.getCreatedAt().isAfter(LocalDateTime.now().minusDays(1))); + + if (!exists) { + createNotification( + title, + "Ritz Coupon code '" + coupon.getCode() + "' is set to expire on " + coupon.getExpiryDate() + ". Consider extending or updating it.", + "COUPON", + "/ritz/coupons" + ); + } + } + } +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 006c482d..7964e4f3 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -44,8 +44,11 @@ const PAGES = [ const Header = () => { const navigate = useNavigate(); const searchRef = useRef(null); + const notificationRef = useRef(null); const [searchTerm, setSearchTerm] = useState(''); const [showResults, setShowResults] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); + const [notifications, setNotifications] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const filteredPages = PAGES.filter(page => @@ -60,25 +63,47 @@ const Header = () => { day: 'numeric', }); + const fetchNotifications = async () => { + try { + const response = await fetch(`http://${window.location.hostname}:8080/api/notifications`); + if (response.ok) { + const data = await response.json(); + setNotifications(data); + } + } catch (error) { + console.error('Error fetching notifications:', error); + } + }; + + useEffect(() => { + fetchNotifications(); + const interval = setInterval(fetchNotifications, 30000); // Poll every 30s + return () => clearInterval(interval); + }, []); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(event.target as Node)) { setShowResults(false); } + if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) { + setShowNotifications(false); + } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { + if (e.key === 'ArrowDown' && showResults) { setSelectedIndex(prev => (prev + 1) % filteredPages.length); - } else if (e.key === 'ArrowUp') { + } else if (e.key === 'ArrowUp' && showResults) { setSelectedIndex(prev => (prev - 1 + filteredPages.length) % filteredPages.length); - } else if (e.key === 'Enter' && filteredPages[selectedIndex]) { + } else if (e.key === 'Enter' && showResults && filteredPages[selectedIndex]) { handleNavigate(filteredPages[selectedIndex].path); } else if (e.key === 'Escape') { setShowResults(false); + setShowNotifications(false); } }; @@ -88,6 +113,37 @@ const Header = () => { setShowResults(false); }; + const handleNotificationClick = async (notif: any) => { + try { + await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-read/${notif.id}`, { method: 'POST' }); + if (notif.link) navigate(notif.link); + setShowNotifications(false); + fetchNotifications(); + } catch (error) { + console.error('Error marking notification as read:', error); + } + }; + + const markAllAsRead = async () => { + try { + await fetch(`http://${window.location.hostname}:8080/api/notifications/mark-all-read`, { method: 'POST' }); + fetchNotifications(); + setShowNotifications(false); + } catch (error) { + console.error('Error marking all as read:', error); + } + }; + + const getNotificationIcon = (type: string) => { + switch(type) { + case 'FEEDBACK': return ; + case 'PURCHASE': return ; + case 'PRODUCT': return ; + case 'COUPON': return ; + default: return ; + } + }; + return (
@@ -161,10 +217,72 @@ const Header = () => {
- +
+ + + + {showNotifications && ( + +
+

Notifications

+ {notifications.length > 0 && ( + + )} +
+ +
+ {notifications.length > 0 ? ( + notifications.map((notif) => ( + + )) + ) : ( +
+
+ +
+

All caught up!

+
+ )} +
+
+ )} +
+