Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
493 changes: 221 additions & 272 deletions RestroHub-FrontEnd/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion RestroHub-FrontEnd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@react-oauth/google": "^0.13.5",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@stomp/stompjs": "^7.3.0",
"axios": "^1.13.4",
"formik": "^2.4.9",
"framer-motion": "^12.33.0",
Expand All @@ -29,6 +30,7 @@
"react-qr-code": "^2.0.18",
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0",
"sockjs-client": "^1.6.1",
"three": "^0.182.0",
"yup": "^1.7.1"
},
Expand Down Expand Up @@ -56,4 +58,4 @@
],
"author": "",
"license": "MIT"
}
}
2 changes: 2 additions & 0 deletions RestroHub-FrontEnd/src/components/admin/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
QrCode,
ChevronsLeft,
ChevronsRight,
ChefHat,
} from 'lucide-react';
import { useAdminTheme } from '@context/AdminThemeContext';

Expand Down Expand Up @@ -61,6 +62,7 @@ const Sidebar = ({ open, setOpen, collapsed, setCollapsed }) => {
label: 'Menu',
items: [
{ type: 'link', name: 'Dashboard', path: '/admin/dashboard', icon: LayoutDashboard },
{ type: 'link', name: 'Kitchen Display', path: '/admin/kds', icon: ChefHat },
{ type: 'link', name: 'Menus', path: '/admin/menus', icon: UtensilsCrossed },
{ type: 'link', name: 'Orders', path: '/admin/orders', icon: ShoppingCart },
],
Expand Down
88 changes: 88 additions & 0 deletions RestroHub-FrontEnd/src/components/admin/kds/KanbanBoard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react';
import OrderCard from './OrderCard';

const KanbanBoard = ({ orders, onStatusUpdate }) => {
// Group orders by status
const pendingOrders = orders.filter(o => o.status === 'PENDING' || o.status === 'CONFIRMED');
const preparingOrders = orders.filter(o => o.status === 'PREPARING');
const readyOrders = orders.filter(o => o.status === 'READY');

return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 h-full overflow-hidden">
{/* Pending Column */}
<div className="flex flex-col h-full bg-gray-50 rounded-xl p-4 border border-gray-200">
<div className="flex justify-between items-center mb-4 pb-2 border-b-2 border-gray-300">
<h2 className="text-xl font-black text-gray-700 uppercase tracking-wider">Pending</h2>
<span className="bg-gray-200 text-gray-800 py-1 px-3 rounded-full font-bold text-sm">
{pendingOrders.length}
</span>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-4 pb-20 custom-scrollbar">
{pendingOrders.map(order => (
<OrderCard key={order.orderId} order={order} onStatusUpdate={onStatusUpdate} />
))}
{pendingOrders.length === 0 && (
<div className="h-32 flex items-center justify-center text-gray-400 font-medium">
No pending orders
</div>
)}
</div>
</div>

{/* Preparing Column */}
<div className="flex flex-col h-full bg-blue-50 rounded-xl p-4 border border-blue-200">
<div className="flex justify-between items-center mb-4 pb-2 border-b-2 border-blue-300">
<h2 className="text-xl font-black text-blue-800 uppercase tracking-wider">Preparing</h2>
<span className="bg-blue-200 text-blue-900 py-1 px-3 rounded-full font-bold text-sm">
{preparingOrders.length}
</span>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-4 pb-20 custom-scrollbar">
{preparingOrders.map(order => (
<OrderCard key={order.orderId} order={order} onStatusUpdate={onStatusUpdate} />
))}
{preparingOrders.length === 0 && (
<div className="h-32 flex items-center justify-center text-blue-300 font-medium">
No orders preparing
</div>
)}
</div>
</div>

{/* Ready Column */}
<div className="flex flex-col h-full bg-green-50 rounded-xl p-4 border border-green-200">
<div className="flex justify-between items-center mb-4 pb-2 border-b-2 border-green-300">
<h2 className="text-xl font-black text-green-800 uppercase tracking-wider">Ready</h2>
<span className="bg-green-200 text-green-900 py-1 px-3 rounded-full font-bold text-sm">
{readyOrders.length}
</span>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-4 pb-20 custom-scrollbar">
{readyOrders.map(order => (
<OrderCard key={order.orderId} order={order} onStatusUpdate={onStatusUpdate} />
))}
{readyOrders.length === 0 && (
<div className="h-32 flex items-center justify-center text-green-300 font-medium">
No orders ready
</div>
)}
</div>
</div>

<style>{`
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 20px;
}
`}</style>
</div>
);
};

export default KanbanBoard;
180 changes: 180 additions & 0 deletions RestroHub-FrontEnd/src/components/admin/kds/KitchenDisplaySystem.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import React, { useState, useEffect, useRef } from 'react';
import { Stomp } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import api from '@services/common/api';
import KanbanBoard from './KanbanBoard';
import { toast } from 'react-hot-toast';

const KitchenDisplaySystem = () => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [isConnected, setIsConnected] = useState(false);
const stompClientRef = useRef(null);
const audioContextRef = useRef(null);

// The selected branch ID could be fetched from context or local storage, assuming branch ID 1 for now if not available
// In a real scenario, this would come from the auth token or selected context
const branchId = localStorage.getItem("selectedBranchId") || 1;

// Initialize Web Audio API for chime
useEffect(() => {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioContextRef.current = new AudioContext();
} catch (e) {
console.warn("Web Audio API not supported", e);
}
return () => {
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
}
};
}, []);

const playChime = () => {
if (!audioContextRef.current) return;

// Resume context if suspended (browser autoplay policy)
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume();
}

try {
const oscillator = audioContextRef.current.createOscillator();
const gainNode = audioContextRef.current.createGain();

oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(587.33, audioContextRef.current.currentTime); // D5
oscillator.frequency.exponentialRampToValueAtTime(880.00, audioContextRef.current.currentTime + 0.1); // A5

gainNode.gain.setValueAtTime(0, audioContextRef.current.currentTime);
gainNode.gain.linearRampToValueAtTime(0.3, audioContextRef.current.currentTime + 0.05);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContextRef.current.currentTime + 1);

oscillator.connect(gainNode);
gainNode.connect(audioContextRef.current.destination);

oscillator.start(audioContextRef.current.currentTime);
oscillator.stop(audioContextRef.current.currentTime + 1);
} catch (e) {
console.error("Audio playback failed", e);
}
};

const fetchActiveOrders = async () => {
try {
setLoading(true);
const response = await api.get(`/secure/api/v1/orders/branch/${branchId}/active`);
setOrders(response.data || []);
} catch (error) {
console.error("Error fetching orders:", error);
toast.error("Failed to load active orders");
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchActiveOrders();

// Connect WebSocket
const connectWebSocket = () => {
const wsUrl = import.meta.env.VITE_WS_URL || "http://localhost:8181/ws";
Comment thread
anshika1179 marked this conversation as resolved.
const socket = new SockJS(wsUrl);
const client = Stomp.over(socket);

// Disable debug logging in production
client.debug = () => {};

client.connect({}, (frame) => {
setIsConnected(true);
console.log('Connected to KDS WebSocket');

client.subscribe(`/topic/orders/branch/${branchId}`, (message) => {
if (message.body) {
const notification = JSON.parse(message.body);
handleOrderNotification(notification);
}
});
}, (error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
// Attempt to reconnect after 5 seconds
setTimeout(connectWebSocket, 5000);
});
Comment thread
anshika1179 marked this conversation as resolved.

stompClientRef.current = client;
};

connectWebSocket();

return () => {
if (stompClientRef.current) {
stompClientRef.current.disconnect();
}
};
}, [branchId]);

const handleOrderNotification = (notification) => {
const { type, order } = notification;

if (type === 'NEW_ORDER') {
playChime();
toast.success(`New order #${order.orderId} received!`, { icon: '🔔' });
setOrders(prev => {
// Prevent duplicates
if (prev.some(o => o.orderId === order.orderId)) return prev;
return [order, ...prev];
});
} else if (type === 'STATUS_UPDATE') {
setOrders(prev => prev.map(o => o.orderId === order.orderId ? order : o));
}
};

const handleStatusUpdate = (orderId, newStatus) => {
// Optimistic update
setOrders(prev => prev.map(o =>
o.orderId === orderId ? { ...o, status: newStatus } : o
));

// In a real application, if the API call fails (handled in OrderCard),
// it would call a rollback function here or re-fetch.
// For simplicity, we assume success or rely on the WebSocket broadcast to correct it.
};

return (
<div className="flex flex-col h-screen bg-gray-100 p-4 pt-20 lg:pt-4">
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-xl shadow-sm">
<div>
<h1 className="text-2xl font-bold text-gray-800">Kitchen Display System</h1>
<div className="flex items-center mt-1">
<div className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-green-500' : 'bg-red-500 animate-pulse'}`}></div>
<span className="text-sm text-gray-500 font-medium">
{isConnected ? 'Live Sync Active' : 'Disconnected - Reconnecting...'}
</span>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={fetchActiveOrders}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-bold transition-colors"
>
Refresh
</button>
</div>
</div>

<div className="flex-1 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : (
<KanbanBoard orders={orders} onStatusUpdate={handleStatusUpdate} />
)}
</div>
</div>
);
};

export default KitchenDisplaySystem;
Loading