Notifications added
This commit is contained in:
@@ -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<Feedback> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Product> 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));
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<PurchaseOrder> 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")
|
||||
|
||||
@@ -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<SystemNotification> getUnread() {
|
||||
return notificationService.getUnreadNotifications();
|
||||
}
|
||||
|
||||
@GetMapping("/all")
|
||||
public List<SystemNotification> 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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<CouponCode, Long> {
|
||||
Optional<CouponCode> findByCode(String code);
|
||||
List<CouponCode> findByExpiryDateBeforeAndIsActiveTrue(LocalDateTime threshold);
|
||||
}
|
||||
|
||||
@@ -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<SystemNotification, Long> {
|
||||
List<SystemNotification> findByIsReadFalseOrderByCreatedAtDesc();
|
||||
List<SystemNotification> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -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<SystemNotification> getUnreadNotifications() {
|
||||
return notificationRepository.findByIsReadFalseOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
public List<SystemNotification> 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<SystemNotification> 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<CouponCode> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,11 @@ const PAGES = [
|
||||
const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
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 <MessageSquare size={16} className="text-emerald-500" />;
|
||||
case 'PURCHASE': return <ShoppingBag size={16} className="text-indigo-500" />;
|
||||
case 'PRODUCT': return <Package size={16} className="text-amber-500" />;
|
||||
case 'COUPON': return <Ticket size={16} className="text-rose-500" />;
|
||||
default: return <Bell size={16} className="text-slate-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white/80 backdrop-blur-md border-b border-[#e2e8f0] px-8 flex items-center justify-between sticky top-0 z-50">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
@@ -161,10 +217,72 @@ const Header = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-l border-[#e2e8f0] pl-6">
|
||||
<button className="relative p-2 text-[#64748b] hover:text-[#1e293b] hover:bg-gray-100 rounded-lg transition-all group">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border-2 border-white"></span>
|
||||
</button>
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className={`relative p-2 rounded-lg transition-all group ${showNotifications ? 'bg-indigo-50 text-[#0f4475]' : 'text-[#64748b] hover:text-[#1e293b] hover:bg-gray-100'}`}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{notifications.length > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 rounded-full border-2 border-white text-[10px] text-white font-bold flex items-center justify-center animate-pulse">
|
||||
{notifications.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showNotifications && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||
className="absolute right-0 top-full mt-2 w-80 bg-white/95 backdrop-blur-xl border border-[#e2e8f0] rounded-2xl shadow-2xl overflow-hidden z-50"
|
||||
>
|
||||
<div className="p-4 border-b border-[#e2e8f0] flex items-center justify-between bg-gray-50/50">
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-wider">Notifications</h3>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-[10px] font-bold text-indigo-600 hover:text-indigo-800 uppercase tracking-tighter"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto custom-scrollbar">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((notif) => (
|
||||
<button
|
||||
key={notif.id}
|
||||
onClick={() => handleNotificationClick(notif)}
|
||||
className="w-full p-4 border-b border-gray-50 hover:bg-gray-50/80 transition-colors flex gap-3 text-left group"
|
||||
>
|
||||
<div className="mt-1 shrink-0 p-2 bg-white rounded-xl shadow-sm border border-gray-100 group-hover:border-indigo-100 transition-colors">
|
||||
{getNotificationIcon(notif.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-black text-slate-800 mb-0.5 truncate">{notif.title}</div>
|
||||
<div className="text-[11px] text-slate-500 font-medium leading-relaxed line-clamp-2">{notif.message}</div>
|
||||
<div className="mt-2 text-[9px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
{new Date(notif.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Bell size={24} className="text-slate-200" />
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">All caught up!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Link to="/settings" className="p-2 text-[#64748b] hover:text-[#0f4475] hover:bg-indigo-50 rounded-lg transition-all">
|
||||
<Settings size={20} />
|
||||
|
||||
Reference in New Issue
Block a user