QR Added to the orders screens.
This commit is contained in:
@@ -7,15 +7,17 @@ public class GeneralDashboardData {
|
|||||||
private DashboardStats stats;
|
private DashboardStats stats;
|
||||||
private List<Map<String, Object>> storeOverview;
|
private List<Map<String, Object>> storeOverview;
|
||||||
private List<Map<String, Object>> hourlySales;
|
private List<Map<String, Object>> hourlySales;
|
||||||
private List<String> insights;
|
private List<Map<String, String>> insights;
|
||||||
|
private List<TrendingItem> trendingItems;
|
||||||
|
|
||||||
public GeneralDashboardData() {}
|
public GeneralDashboardData() {}
|
||||||
|
|
||||||
public GeneralDashboardData(DashboardStats stats, List<Map<String, Object>> storeOverview, List<Map<String, Object>> hourlySales, List<String> insights) {
|
public GeneralDashboardData(DashboardStats stats, List<Map<String, Object>> storeOverview, List<Map<String, Object>> hourlySales, List<Map<String, String>> insights, List<TrendingItem> trendingItems) {
|
||||||
this.stats = stats;
|
this.stats = stats;
|
||||||
this.storeOverview = storeOverview;
|
this.storeOverview = storeOverview;
|
||||||
this.hourlySales = hourlySales;
|
this.hourlySales = hourlySales;
|
||||||
this.insights = insights;
|
this.insights = insights;
|
||||||
|
this.trendingItems = trendingItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DashboardStats getStats() { return stats; }
|
public DashboardStats getStats() { return stats; }
|
||||||
@@ -27,6 +29,9 @@ public class GeneralDashboardData {
|
|||||||
public List<Map<String, Object>> getHourlySales() { return hourlySales; }
|
public List<Map<String, Object>> getHourlySales() { return hourlySales; }
|
||||||
public void setHourlySales(List<Map<String, Object>> hourlySales) { this.hourlySales = hourlySales; }
|
public void setHourlySales(List<Map<String, Object>> hourlySales) { this.hourlySales = hourlySales; }
|
||||||
|
|
||||||
public List<String> getInsights() { return insights; }
|
public List<Map<String, String>> getInsights() { return insights; }
|
||||||
public void setInsights(List<String> insights) { this.insights = insights; }
|
public void setInsights(List<Map<String, String>> insights) { this.insights = insights; }
|
||||||
|
|
||||||
|
public List<TrendingItem> getTrendingItems() { return trendingItems; }
|
||||||
|
public void setTrendingItems(List<TrendingItem> trendingItems) { this.trendingItems = trendingItems; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.rit.canteen.sales.model;
|
||||||
|
|
||||||
|
public class TrendingItem {
|
||||||
|
private String name;
|
||||||
|
private String category;
|
||||||
|
private long qty;
|
||||||
|
private String image;
|
||||||
|
|
||||||
|
public TrendingItem() {}
|
||||||
|
|
||||||
|
public TrendingItem(String name, String category, long qty, String image) {
|
||||||
|
this.name = name;
|
||||||
|
this.category = category;
|
||||||
|
this.qty = qty;
|
||||||
|
this.image = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getCategory() { return category; }
|
||||||
|
public void setCategory(String category) { this.category = category; }
|
||||||
|
|
||||||
|
public long getQty() { return qty; }
|
||||||
|
public void setQty(long qty) { this.qty = qty; }
|
||||||
|
|
||||||
|
public String getImage() { return image; }
|
||||||
|
public void setImage(String image) { this.image = image; }
|
||||||
|
}
|
||||||
@@ -48,10 +48,10 @@ public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecific
|
|||||||
"ORDER BY HOUR(o.createdAt)")
|
"ORDER BY HOUR(o.createdAt)")
|
||||||
List<Object[]> getHourlySales(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
|
List<Object[]> getHourlySales(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
|
||||||
|
|
||||||
@Query("SELECT i.productName, SUM(i.quantity), SUM(i.price * i.quantity) " +
|
@Query("SELECT p.name, p.category, SUM(i.quantity), p.imageData " +
|
||||||
"FROM Order o JOIN o.items i " +
|
"FROM Order o JOIN o.items i JOIN Product p ON i.productId = p.id " +
|
||||||
"WHERE o.createdAt >= :start AND o.createdAt <= :end " +
|
"WHERE o.createdAt >= :start AND o.createdAt <= :end " +
|
||||||
"GROUP BY i.productName " +
|
"GROUP BY p.name, p.category, p.imageData " +
|
||||||
"ORDER BY SUM(i.quantity) DESC")
|
"ORDER BY SUM(i.quantity) DESC")
|
||||||
List<Object[]> getTopSellingItems(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
|
List<Object[]> getTopSellingItems(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.rit.canteen.sales.service;
|
|||||||
import com.rit.canteen.sales.model.DashboardStats;
|
import com.rit.canteen.sales.model.DashboardStats;
|
||||||
import com.rit.canteen.sales.model.GeneralDashboardData;
|
import com.rit.canteen.sales.model.GeneralDashboardData;
|
||||||
import com.rit.canteen.sales.model.Order;
|
import com.rit.canteen.sales.model.Order;
|
||||||
|
import com.rit.canteen.sales.model.TrendingItem;
|
||||||
import com.rit.canteen.sales.repository.OrderRepository;
|
import com.rit.canteen.sales.repository.OrderRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -26,6 +27,7 @@ public class DashboardService {
|
|||||||
|
|
||||||
DashboardStats stats = getDashboardStats(from, to);
|
DashboardStats stats = getDashboardStats(from, to);
|
||||||
|
|
||||||
|
System.out.println("Fetching dashboard data for range: " + from + " to " + to);
|
||||||
// 1. Store Overview
|
// 1. Store Overview
|
||||||
List<Object[]> storeData = orderRepository.getStoreOverview(from, to);
|
List<Object[]> storeData = orderRepository.getStoreOverview(from, to);
|
||||||
List<Map<String, Object>> storeOverview = new ArrayList<>();
|
List<Map<String, Object>> storeOverview = new ArrayList<>();
|
||||||
@@ -59,17 +61,54 @@ public class DashboardService {
|
|||||||
hourlySales.add(hourMap);
|
hourlySales.add(hourMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Dynamic Insights
|
// 3. Trending Items
|
||||||
List<String> insights = new ArrayList<>();
|
List<Object[]> trendingData = orderRepository.getTopSellingItems(from, to);
|
||||||
if (stats.getActiveOrders() > 0) {
|
List<TrendingItem> trendingItems = new ArrayList<>();
|
||||||
insights.add(stats.getActiveOrders() + " orders today! Clearly the crowd's found their happy place 💃🕺");
|
for (int i = 0; i < Math.min(trendingData.size(), 4); i++) {
|
||||||
BigDecimal avg = stats.getTotalSales() > 0 ? BigDecimal.valueOf(stats.getTotalSales()).divide(BigDecimal.valueOf(stats.getActiveOrders()), 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
Object[] row = trendingData.get(i);
|
||||||
insights.add("₹" + avg + " average order value! Proof that happy bellies don't need heavy bills 🤤🔥");
|
String name = (String) row[0];
|
||||||
} else {
|
String category = (String) row[1];
|
||||||
insights.add("Waiting for the first orders of the day to roll in... ☕");
|
long qty = ((Number) row[2]).longValue();
|
||||||
|
String imageData = (String) row[3];
|
||||||
|
|
||||||
|
// Format image data for frontend
|
||||||
|
String imageUrl = imageData != null ? (imageData.startsWith("http") ? imageData : (imageData.startsWith("data:") ? imageData : "data:image/png;base64," + imageData)) : "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=200&q=80";
|
||||||
|
|
||||||
|
trendingItems.add(new TrendingItem(name, category, qty, imageUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new GeneralDashboardData(stats, storeOverview, hourlySales, insights);
|
// 4. Dynamic Insights
|
||||||
|
List<Map<String, String>> insights = new ArrayList<>();
|
||||||
|
if (stats.getActiveOrders() > 0) {
|
||||||
|
Map<String, String> orderInsight = new HashMap<>();
|
||||||
|
orderInsight.put("text", stats.getActiveOrders() + " orders at RIT Canteen! Clearly the crowd's found their happy place 💃🕺");
|
||||||
|
orderInsight.put("color", "bg-rose-50 text-rose-600 border-rose-100");
|
||||||
|
insights.add(orderInsight);
|
||||||
|
|
||||||
|
BigDecimal avg = stats.getTotalSales() > 0 ? BigDecimal.valueOf(stats.getTotalSales()).divide(BigDecimal.valueOf(stats.getActiveOrders()), 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
||||||
|
Map<String, String> avgInsight = new HashMap<>();
|
||||||
|
avgInsight.put("text", "₹" + avg + " average order value! Either everyone's hungry or just living large 🔥😋");
|
||||||
|
avgInsight.put("color", "bg-emerald-50 text-emerald-600 border-emerald-100");
|
||||||
|
insights.add(avgInsight);
|
||||||
|
|
||||||
|
Map<String, String> customerInsight = new HashMap<>();
|
||||||
|
customerInsight.put("text", "RIT Canteen had " + stats.getActiveOrders() + " orders but only " + stats.getDailyCustomers() + " customers — Maybe customers are shy! 🥰");
|
||||||
|
customerInsight.put("color", "bg-orange-50 text-orange-600 border-orange-100");
|
||||||
|
insights.add(customerInsight);
|
||||||
|
|
||||||
|
Map<String, String> revenueInsight = new HashMap<>();
|
||||||
|
revenueInsight.put("text", "RIT Canteen clocked ₹" + String.format("%,d", stats.getTotalSales()) + " — ka-ching! That's called business booming 💸📈");
|
||||||
|
revenueInsight.put("color", "bg-blue-50 text-blue-600 border-blue-100");
|
||||||
|
insights.add(revenueInsight);
|
||||||
|
} else {
|
||||||
|
Map<String, String> emptyInsight = new HashMap<>();
|
||||||
|
emptyInsight.put("text", "Waiting for the first orders of the day to roll in... ☕");
|
||||||
|
emptyInsight.put("color", "bg-indigo-50 text-indigo-600 border-indigo-100");
|
||||||
|
insights.add(emptyInsight);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("Dashboard data generated successfully with " + trendingItems.size() + " trending items.");
|
||||||
|
return new GeneralDashboardData(stats, storeOverview, hourlySales, insights, trendingItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DashboardStats getDashboardStats(LocalDateTime from, LocalDateTime to) {
|
public DashboardStats getDashboardStats(LocalDateTime from, LocalDateTime to) {
|
||||||
|
|||||||
0
backend/src/main/resources/canteen.db
Normal file
0
backend/src/main/resources/canteen.db
Normal file
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
@@ -3516,6 +3517,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/raf": {
|
"node_modules/raf": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ArrowLeft
|
ArrowLeft
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
import { QRCodeCanvas } from 'qrcode.react';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
@@ -336,7 +337,7 @@ const ArchivedOrders: React.FC = () => {
|
|||||||
{/* Bottom Totals Summary */}
|
{/* Bottom Totals Summary */}
|
||||||
<div className="bg-slate-900 p-8 text-white">
|
<div className="bg-slate-900 p-8 text-white">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="flex gap-12">
|
<div className="flex gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-2">Unique Items</div>
|
<div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-2">Unique Items</div>
|
||||||
<div className="text-2xl font-black">{selectedOrder.items.length}</div>
|
<div className="text-2xl font-black">{selectedOrder.items.length}</div>
|
||||||
@@ -347,6 +348,32 @@ const ArchivedOrders: React.FC = () => {
|
|||||||
<Clock size={12} /> Closed File
|
<Clock size={12} /> Closed File
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Verification with Status Blur */}
|
||||||
|
<div className="flex items-center gap-5 pl-12 border-l border-white/10">
|
||||||
|
<div className="relative flex items-center justify-center bg-white/5 p-2 rounded-xl border border-white/10 group/qr transition-all">
|
||||||
|
<div className={`transition-all duration-700 ${['COMPLETED', 'CANCELLED'].includes(selectedOrder.status.toUpperCase()) ? 'blur-[3px] opacity-20' : ''}`}>
|
||||||
|
<QRCodeCanvas
|
||||||
|
value={selectedOrder.orderNumber}
|
||||||
|
size={64}
|
||||||
|
level="H"
|
||||||
|
bgColor="transparent"
|
||||||
|
fgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{['COMPLETED', 'CANCELLED'].includes(selectedOrder.status.toUpperCase()) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rotate-[-12deg] pointer-events-none">
|
||||||
|
<span className={`text-[10px] font-black text-white px-3 py-1 rounded-lg border border-white/20 shadow-2xl uppercase tracking-[0.2em] backdrop-blur-md ${selectedOrder.status.toUpperCase() === 'COMPLETED' ? 'bg-emerald-500/80' : 'bg-rose-500/80'}`}>
|
||||||
|
{selectedOrder.status.toUpperCase() === 'COMPLETED' ? 'DELIVERED' : 'EXPIRED'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-500 uppercase font-black tracking-widest mb-0.5">Verification</div>
|
||||||
|
<div className="text-[10px] font-black text-white uppercase tracking-tighter opacity-80">Ledger Sync QR</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex flex-col gap-1 min-w-[250px]">
|
<div className="text-right flex flex-col gap-1 min-w-[250px]">
|
||||||
<div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-1">Final Settlement</div>
|
<div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-1">Final Settlement</div>
|
||||||
|
|||||||
@@ -328,15 +328,15 @@ const Dashboard = () => {
|
|||||||
<div className="col-span-12 lg:col-span-5 bg-white rounded-[32px] border border-slate-100 shadow-sm p-8 flex flex-col">
|
<div className="col-span-12 lg:col-span-5 bg-white rounded-[32px] border border-slate-100 shadow-sm p-8 flex flex-col">
|
||||||
<h3 className="text-sm font-black text-slate-800 tracking-tight mb-8 uppercase tracking-widest">Business Intelligence</h3>
|
<h3 className="text-sm font-black text-slate-800 tracking-tight mb-8 uppercase tracking-widest">Business Intelligence</h3>
|
||||||
<div className="space-y-4 flex-1">
|
<div className="space-y-4 flex-1">
|
||||||
{insights.map((insight: string, idx: number) => (
|
{insights.map((insight: any, idx: number) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: -20, opacity: 0 }}
|
initial={{ x: -20, opacity: 0 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
transition={{ delay: idx * 0.1 }}
|
transition={{ delay: idx * 0.1 }}
|
||||||
key={idx}
|
key={idx}
|
||||||
className="p-5 rounded-2xl border border-slate-100 bg-slate-50/50 hover:bg-white hover:shadow-md hover:border-transparent transition-all cursor-default"
|
className={`p-5 rounded-2xl border transition-all cursor-default ${insight.color || 'border-slate-100 bg-slate-50/50 hover:bg-white hover:shadow-md hover:border-transparent'}`}
|
||||||
>
|
>
|
||||||
<p className="text-[11px] font-black text-slate-600 leading-relaxed uppercase tracking-tight">{insight}</p>
|
<p className="text-[11px] font-black text-slate-600 leading-relaxed uppercase tracking-tight">{insight.text || insight}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X as XIcon,
|
X as XIcon,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Edit2
|
Edit2,
|
||||||
|
Printer
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { QRCodeCanvas } from 'qrcode.react';
|
||||||
import Pagination from '../components/Pagination';
|
import Pagination from '../components/Pagination';
|
||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
@@ -75,6 +77,8 @@ const Orders: React.FC = () => {
|
|||||||
const [editSearchQuery, setEditSearchQuery] = useState('');
|
const [editSearchQuery, setEditSearchQuery] = useState('');
|
||||||
const [editingItems, setEditingItems] = useState<OrderItem[]>([]);
|
const [editingItems, setEditingItems] = useState<OrderItem[]>([]);
|
||||||
const [isUpdatingOrder, setIsUpdatingOrder] = useState(false);
|
const [isUpdatingOrder, setIsUpdatingOrder] = useState(false);
|
||||||
|
const [showQRMenu, setShowQRMenu] = useState(false);
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
|
||||||
// Debounce search query
|
// Debounce search query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -249,6 +253,72 @@ const Orders: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePrintQR = () => {
|
||||||
|
const canvas = document.querySelector('.qr-canvas canvas') as HTMLCanvasElement;
|
||||||
|
if (!canvas || !selectedOrder) return;
|
||||||
|
|
||||||
|
const qrDataURL = canvas.toDataURL('image/png');
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Print QR Code - Order #${selectedOrder.displayOrderId}</title>
|
||||||
|
<style>
|
||||||
|
body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; font-family: sans-serif; margin: 0; }
|
||||||
|
img { width: 300px; height: 300px; }
|
||||||
|
.order-info { margin-top: 40px; text-align: center; }
|
||||||
|
h1 { margin: 0; font-size: 24px; }
|
||||||
|
p { margin: 5px 0; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${qrDataURL}" />
|
||||||
|
<div class="order-info">
|
||||||
|
<h1>Order #${selectedOrder.displayOrderId}</h1>
|
||||||
|
<p>Scan to verify at terminal</p>
|
||||||
|
<p>Customer: ${selectedOrder.user?.name || 'Walk-in'}</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
window.onload = () => {
|
||||||
|
window.print();
|
||||||
|
setTimeout(() => window.close(), 100);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerateQR = async () => {
|
||||||
|
if (!selectedOrder) return;
|
||||||
|
setIsRegenerating(true);
|
||||||
|
try {
|
||||||
|
const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`;
|
||||||
|
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...selectedOrder,
|
||||||
|
orderNumber: newOrderNumber
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
fetchOrders(); // This will refresh the whole list and the selected order
|
||||||
|
setShowQRMenu(false);
|
||||||
|
} else {
|
||||||
|
alert('Failed to regenerate sync ID');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating QR:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
if (!editSearchQuery.trim()) return [];
|
if (!editSearchQuery.trim()) return [];
|
||||||
return allProducts.filter(p =>
|
return allProducts.filter(p =>
|
||||||
@@ -583,6 +653,50 @@ const Orders: React.FC = () => {
|
|||||||
{selectedOrder.items.reduce((sum, item) => sum + item.quantity, 0)}
|
{selectedOrder.items.reduce((sum, item) => sum + item.quantity, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* QR Verification Section */}
|
||||||
|
<div className="flex items-center gap-4 pl-8 border-l border-slate-200">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
onClick={() => setShowQRMenu(!showQRMenu)}
|
||||||
|
className="p-1.5 bg-white rounded-lg border border-slate-200 shadow-sm transition-all hover:scale-105 hover:border-indigo-300 cursor-pointer qr-canvas relative group"
|
||||||
|
>
|
||||||
|
<QRCodeCanvas
|
||||||
|
value={selectedOrder.orderNumber}
|
||||||
|
size={56}
|
||||||
|
level="H"
|
||||||
|
includeMargin={false}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-indigo-600/0 group-hover:bg-indigo-600/5 transition-colors rounded-lg flex items-center justify-center">
|
||||||
|
<Plus size={16} className="text-indigo-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQRMenu && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-4 w-64 bg-white border border-slate-200 rounded-2xl shadow-2xl z-20 overflow-hidden py-1 animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||||
|
<div className="px-4 py-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest border-b border-slate-50">Sync Actions</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePrintQR}
|
||||||
|
className="w-full text-left px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50 flex items-center gap-3 transition-colors"
|
||||||
|
>
|
||||||
|
<Printer size={16} className="text-emerald-500" /> Print Sync Token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerateQR}
|
||||||
|
disabled={isRegenerating}
|
||||||
|
className="w-full text-left px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50 flex items-center gap-3 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={`text-indigo-500 ${isRegenerating ? 'animate-spin' : ''}`} />
|
||||||
|
{isRegenerating ? 'Regenerating...' : 'Regenerate Sync ID'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[9px] text-slate-400 uppercase font-black tracking-widest mb-0.5">Verify Order</div>
|
||||||
|
<div className="text-[10px] font-black text-slate-800 uppercase tracking-tighter">Scan for Terminal Sync</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex flex-col gap-2 min-w-[200px]">
|
<div className="text-right flex flex-col gap-2 min-w-[200px]">
|
||||||
<div className="flex justify-between items-center text-xs text-slate-500 font-bold">
|
<div className="flex justify-between items-center text-xs text-slate-500 font-bold">
|
||||||
|
|||||||
@@ -21,75 +21,91 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
const salesData = [
|
|
||||||
{ time: '12am', value: 0 },
|
|
||||||
{ time: '2am', value: 0 },
|
|
||||||
{ time: '4am', value: 0 },
|
|
||||||
{ time: '6am', value: 0 },
|
|
||||||
{ time: '8am', value: 5000 },
|
|
||||||
{ time: '10am', value: 16000 },
|
|
||||||
{ time: '12pm', value: 6000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const trendingItems = [
|
|
||||||
{ name: "GHEE ROAST", category: "BREAK FAST", qty: 64, image: "https://images.unsplash.com/photo-1668236543090-82eba5ee5976?w=200&q=80" },
|
|
||||||
{ name: "KAL DOSA (2pcs)", category: "BREAK FAST", qty: 60, image: "https://images.unsplash.com/photo-1589301760014-d929f3979dbc?w=200&q=80" },
|
|
||||||
{ name: "VEG S.W", category: "SNACKS", qty: 51, image: "https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=200&q=80" },
|
|
||||||
{ name: "PLAIN DOSA", category: "BREAK FAST", qty: 51, image: "https://images.unsplash.com/photo-1610192244261-3f33de3f55e4?w=200&q=80" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const StoreDashboard = () => {
|
const StoreDashboard = () => {
|
||||||
const [activeTab, setActiveTab] = useState('Sales');
|
const [activeTab, setActiveTab] = useState('Sales');
|
||||||
const [timeRange, setTimeRange] = useState('Today');
|
const [timeRange, setTimeRange] = useState('Today');
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalSales: 48438,
|
totalSales: 0,
|
||||||
activeOrders: 912,
|
activeOrders: 0,
|
||||||
dailyCustomers: 813,
|
dailyCustomers: 0,
|
||||||
revenueGrowth: 12.5
|
revenueGrowth: 0
|
||||||
});
|
});
|
||||||
|
const [trendingItems, setTrendingItems] = useState<any[]>([]);
|
||||||
|
const [salesData, setSalesData] = useState<any[]>([]);
|
||||||
|
const [insights, setInsights] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const formatCurrency = (val: any) => {
|
||||||
|
const num = Number(val);
|
||||||
|
return isNaN(num) ? '0' : num.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/dashboard/stats');
|
const response = await fetch('/api/dashboard/stats');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Find RIT Canteen in storeOverview if available, otherwise use general stats
|
console.log('Dashboard data received successfully:', data);
|
||||||
|
|
||||||
|
// Set basic stats from general stats if available
|
||||||
|
if (data.stats) {
|
||||||
|
setStats({
|
||||||
|
totalSales: data.stats.totalSales,
|
||||||
|
activeOrders: data.stats.activeOrders,
|
||||||
|
dailyCustomers: data.stats.dailyCustomers,
|
||||||
|
revenueGrowth: data.stats.growth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with RIT Canteen specific data if found in overview
|
||||||
if (data.storeOverview && data.storeOverview.length > 0) {
|
if (data.storeOverview && data.storeOverview.length > 0) {
|
||||||
const ritStore = data.storeOverview.find((s: any) => s.name === 'RIT Canteen');
|
const ritStore = data.storeOverview.find((s: any) => s.name === 'RIT Canteen');
|
||||||
if (ritStore) {
|
if (ritStore) {
|
||||||
setStats({
|
setStats(prev => ({
|
||||||
|
...prev,
|
||||||
totalSales: ritStore.sale,
|
totalSales: ritStore.sale,
|
||||||
activeOrders: ritStore.orders,
|
activeOrders: ritStore.orders,
|
||||||
dailyCustomers: ritStore.orders * 0.9, // Approximation
|
dailyCustomers: ritStore.orders * 0.9, // Approximation
|
||||||
revenueGrowth: 12.5
|
}));
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setStats(data.stats);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setStats(data.stats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set other dynamic data
|
||||||
|
if (data.trendingItems) setTrendingItems(data.trendingItems);
|
||||||
|
if (data.hourlySales) setSalesData(data.hourlySales);
|
||||||
|
if (data.insights) setInsights(data.insights);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching dashboard stats:', error);
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pieData = [
|
const pieData = [
|
||||||
{ name: 'Full Payment', value: stats.totalSales, color: '#8b5cf6' },
|
{ name: 'Full Payment', value: Number(stats.totalSales) || 0, color: '#8b5cf6' },
|
||||||
{ name: 'Credit', value: 0, color: '#fbbf24' }
|
{ name: 'Credit', value: 0, color: '#fbbf24' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const insights = [
|
if (isLoading) {
|
||||||
{ text: `${stats.activeOrders} orders at RIT Canteen! Clearly the crowd's found their happy place 💃🕺`, color: "bg-rose-50 text-rose-600 border-rose-100" },
|
return (
|
||||||
{ text: `₹${(stats.totalSales / (stats.activeOrders || 1)).toFixed(2)} average order value at RIT Canteen! Either everyone's hungry or just living large 🔥😋`, color: "bg-emerald-50 text-emerald-600 border-emerald-100" },
|
<div className="h-screen flex items-center justify-center bg-slate-50/50">
|
||||||
{ text: `RIT Canteen had ${stats.activeOrders} orders but only ${Math.round(stats.dailyCustomers)} customers — Maybe customers are shy and didn't give their names 🥰`, color: "bg-orange-50 text-orange-600 border-orange-100" },
|
<motion.div
|
||||||
{ text: `RIT Canteen clocked ₹${stats.totalSales.toLocaleString()} — ka-ching! That's called business booming 💸📈`, color: "bg-blue-50 text-blue-600 border-blue-100" },
|
animate={{ scale: [1, 1.1, 1], opacity: [0.5, 1, 0.5] }}
|
||||||
{ text: "RIT Canteen reaches its morning sales peak around 9AM — breakfast rush in full swing 🍳🥞", color: "bg-indigo-50 text-indigo-600 border-indigo-100" }
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
];
|
className="text-[#0f4475] font-black uppercase tracking-widest text-sm"
|
||||||
|
>
|
||||||
|
Analyzing Store Data...
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8 bg-slate-50/50 min-h-screen font-inter">
|
<div className="p-8 space-y-8 bg-slate-50/50 min-h-screen font-inter">
|
||||||
@@ -200,7 +216,7 @@ const StoreDashboard = () => {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||||
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">₹{stats.totalSales.toLocaleString()}</h2>
|
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">₹{formatCurrency(stats.totalSales)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 mt-4">
|
<div className="flex gap-6 mt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -258,6 +258,37 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regenerate-sync-btn {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-sync-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-sync-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.regenerate-sync-btn.loading {
|
||||||
|
color: var(--text-mid);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-info-list {
|
.modal-info-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const MyOrdersScreen: React.FC = () => {
|
|||||||
const [showStockAlert, setShowStockAlert] = useState(false);
|
const [showStockAlert, setShowStockAlert] = useState(false);
|
||||||
const [unavailableItems, setUnavailableItems] = useState<string[]>([]);
|
const [unavailableItems, setUnavailableItems] = useState<string[]>([]);
|
||||||
const [pendingItems, setPendingItems] = useState<FoodItem[]>([]);
|
const [pendingItems, setPendingItems] = useState<FoodItem[]>([]);
|
||||||
|
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||||
|
|
||||||
const isOrderExpired = (order: Order) => {
|
const isOrderExpired = (order: Order) => {
|
||||||
if (order.isArchived) return true;
|
if (order.isArchived) return true;
|
||||||
@@ -55,6 +56,27 @@ const MyOrdersScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Polling for live updates (Sync when Dashboard regenerates QR or changes status)
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
// Only poll if there are active (non-finalized) orders
|
||||||
|
const hasActiveOrders = orders.some(o =>
|
||||||
|
o.status.toUpperCase() !== 'COMPLETED' &&
|
||||||
|
o.status.toUpperCase() !== 'CANCELLED'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user?.id && hasActiveOrders) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, 10000); // Poll every 10 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [user?.id, orders]);
|
||||||
|
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/user/${user?.id}`);
|
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/user/${user?.id}`);
|
||||||
@@ -106,6 +128,40 @@ const MyOrdersScreen: React.FC = () => {
|
|||||||
navigate('/cart');
|
navigate('/cart');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRegenerateQR = async () => {
|
||||||
|
if (!selectedOrder) return;
|
||||||
|
setIsRegenerating(true);
|
||||||
|
try {
|
||||||
|
const newOrderNumber = `ORD-${Math.random().toString(36).substring(2, 10).toUpperCase()}`;
|
||||||
|
const response = await fetch(`http://${window.location.hostname}:8080/api/orders/${selectedOrder.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...selectedOrder,
|
||||||
|
orderNumber: newOrderNumber
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchOrders();
|
||||||
|
// Since selectedOrder is used in the modal, we need to update it as well
|
||||||
|
const updated = orders.find(o => o.id === selectedOrder.id);
|
||||||
|
if (updated) setSelectedOrder({ ...updated, orderNumber: newOrderNumber });
|
||||||
|
else fetchOrders().then(() => {
|
||||||
|
// Fallback refresh to catch the update
|
||||||
|
setOrders(prev => prev.map(o => o.id === selectedOrder.id ? { ...o, orderNumber: newOrderNumber } : o));
|
||||||
|
setSelectedOrder(prev => prev ? { ...prev, orderNumber: newOrderNumber } : null);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Failed to regenerate sync code');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating QR:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRegenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProceedPartial = () => {
|
const handleProceedPartial = () => {
|
||||||
performRepeat(pendingItems);
|
performRepeat(pendingItems);
|
||||||
setShowStockAlert(false);
|
setShowStockAlert(false);
|
||||||
@@ -265,6 +321,14 @@ const MyOrdersScreen: React.FC = () => {
|
|||||||
? 'This order has been fulfilled'
|
? 'This order has been fulfilled'
|
||||||
: 'Show this QR code at the counter'}
|
: 'Show this QR code at the counter'}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
className={`regenerate-sync-btn ${isRegenerating ? 'loading' : ''}`}
|
||||||
|
onClick={handleRegenerateQR}
|
||||||
|
disabled={isRegenerating || isOrderExpired(selectedOrder) || selectedOrder.status.toUpperCase() === 'COMPLETED'}
|
||||||
|
>
|
||||||
|
<RefreshCcw size={14} className={isRegenerating ? 'animate-spin' : ''} />
|
||||||
|
{isRegenerating ? 'Generating...' : 'Regenerate Sync ID'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-info-list">
|
<div className="modal-info-list">
|
||||||
|
|||||||
@@ -105,21 +105,6 @@ const ProfileScreen: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Stats Section */}
|
|
||||||
<section className="profile-stats">
|
|
||||||
<div className="stat-box" onClick={() => navigate('/orders')}>
|
|
||||||
<span className="stat-value">--</span>
|
|
||||||
<span className="stat-label">Orders</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-box">
|
|
||||||
<span className="stat-value">₹0</span>
|
|
||||||
<span className="stat-label">Spent</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-box">
|
|
||||||
<span className="stat-value">0</span>
|
|
||||||
<span className="stat-label">Coins</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Menu Section */}
|
{/* Menu Section */}
|
||||||
<section className="profile-menu">
|
<section className="profile-menu">
|
||||||
|
|||||||
Reference in New Issue
Block a user