Added counter-frontend module to the repository
This commit is contained in:
13
counter-frontend/index.html
Normal file
13
counter-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Counter POS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3576
counter-frontend/package-lock.json
generated
Normal file
3576
counter-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
counter-frontend/package.json
Normal file
41
counter-frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "counter-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
36
counter-frontend/src/App.tsx
Normal file
36
counter-frontend/src/App.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import POS from './pages/POS';
|
||||
import Orders from './pages/Orders';
|
||||
import Categories from './pages/Categories';
|
||||
import Products from './pages/Products';
|
||||
import Layout from './components/Layout';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isLoggedIn = sessionStorage.getItem('isCounterLoggedIn') === 'true';
|
||||
return isLoggedIn ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<Navigate to="/pos" replace />} />
|
||||
<Route path="pos" element={<POS />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="categories" element={<Categories />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
counter-frontend/src/assets/college-logo.png
Normal file
BIN
counter-frontend/src/assets/college-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
counter-frontend/src/assets/ritchennai.webp
Normal file
BIN
counter-frontend/src/assets/ritchennai.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
121
counter-frontend/src/components/Layout.tsx
Normal file
121
counter-frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShoppingBag,
|
||||
History,
|
||||
Layers,
|
||||
Package,
|
||||
LogOut,
|
||||
Menu,
|
||||
Bell,
|
||||
Search,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
|
||||
import CollegeLogo from '../assets/college-logo.png';
|
||||
|
||||
const Layout = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const userName = sessionStorage.getItem('counterUserName') || 'Counter User';
|
||||
|
||||
const handleLogout = () => {
|
||||
sessionStorage.removeItem('isCounterLoggedIn');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ path: '/pos', icon: ShoppingBag, label: 'POS' },
|
||||
{ path: '/orders', icon: History, label: 'Orders' },
|
||||
{ path: '/categories', icon: Layers, label: 'Categories' },
|
||||
{ path: '/products', icon: Package, label: 'Products' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] flex font-inter overflow-hidden h-screen">
|
||||
{/* Side Navigation Bar */}
|
||||
<aside className="w-64 bg-[#231651] text-white flex flex-col shadow-2xl z-50">
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-white rounded-2xl p-4 flex items-center justify-center border border-white/20 shadow-xl overflow-hidden">
|
||||
<img src={CollegeLogo} alt="Logo" className="h-12 w-auto object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-black tracking-tighter text-white">RIT COUNTER</span>
|
||||
<p className="text-[10px] font-bold text-white/40 uppercase tracking-widest mt-1">Institutional POS</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-4 px-6 py-4 rounded-2xl text-sm font-bold transition-all ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white shadow-lg'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} className={isActive ? 'text-white' : 'text-white/50'} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="bg-white/5 rounded-[2rem] p-6 border border-white/10 group cursor-pointer relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-white border border-white/10">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-xs font-black truncate">{userName}</p>
|
||||
<p className="text-[10px] font-bold text-white/40 uppercase tracking-widest">Operator</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 w-full flex items-center justify-center gap-2 py-3 bg-rose-500/20 text-rose-400 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-rose-500 hover:text-white transition-all"
|
||||
>
|
||||
<LogOut size={14} /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Header for Mobile/Search/Notifications */}
|
||||
<header className="h-20 bg-white border-b border-slate-100 flex items-center justify-between px-8 shrink-0">
|
||||
<div className="flex items-center bg-slate-50 border border-slate-100 rounded-2xl px-6 py-2.5 gap-4 w-full max-w-xl focus-within:bg-white focus-within:ring-4 focus-within:ring-primary/5 transition-all">
|
||||
<Search size={18} className="text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anything here..."
|
||||
className="bg-transparent border-none outline-none text-sm font-medium w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="relative w-12 h-12 flex items-center justify-center bg-slate-50 rounded-2xl text-slate-400 hover:text-primary hover:bg-primary/5 transition-all">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-3 right-3 w-2 h-2 bg-rose-500 rounded-full border-2 border-white"></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden relative">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
81
counter-frontend/src/components/Pagination.tsx
Normal file
81
counter-frontend/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalElements: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalElements,
|
||||
onPageChange,
|
||||
onPageSizeChange
|
||||
}) => {
|
||||
const totalPages = Math.ceil(totalElements / pageSize);
|
||||
const startElement = currentPage * pageSize + 1;
|
||||
const endElement = Math.min((currentPage + 1) * pageSize, totalElements);
|
||||
|
||||
if (totalElements === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-6 border-t border-slate-100 flex flex-col sm:flex-row items-center justify-between gap-6 bg-white shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-black text-slate-400 uppercase tracking-widest leading-none">Show</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-xs font-black text-slate-900 outline-none focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all appearance-none pr-8 relative"
|
||||
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%2394a3b8\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'%3E%3C/path%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 0.5rem center', backgroundSize: '1rem' }}
|
||||
>
|
||||
{[5, 10, 25, 50].map(size => (
|
||||
<option key={size} value={size}>{size} items</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest whitespace-nowrap">
|
||||
{startElement}-{endElement} of {totalElements} units
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl border border-slate-100 text-slate-400 hover:text-primary hover:bg-primary/5 disabled:opacity-30 disabled:hover:bg-transparent transition-all"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i)}
|
||||
className={`w-10 h-10 font-bold text-xs rounded-xl transition-all ${
|
||||
currentPage === i
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-slate-400 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
)).slice(Math.max(0, currentPage - 1), Math.min(totalPages, currentPage + 2))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl border border-slate-100 text-slate-400 hover:text-primary hover:bg-primary/5 disabled:opacity-30 disabled:hover:bg-transparent transition-all"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
8
counter-frontend/src/index.css
Normal file
8
counter-frontend/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #231651;
|
||||
--color-secondary: #64748b;
|
||||
--color-background: #f8fafc;
|
||||
--font-inter: "Inter", sans-serif;
|
||||
}
|
||||
10
counter-frontend/src/main.tsx
Normal file
10
counter-frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
256
counter-frontend/src/pages/Categories.tsx
Normal file
256
counter-frontend/src/pages/Categories.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Package,
|
||||
Search,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
X,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const Categories = () => {
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [currentCategory, setCurrentCategory] = useState<any>(null);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/base-items`);
|
||||
const data = await response.json();
|
||||
setCategories(Array.isArray(data) ? data : (data.content || []));
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const host = window.location.hostname;
|
||||
const url = currentCategory?.id
|
||||
? `http://${host}:8080/api/base-items/${currentCategory.id}`
|
||||
: `http://${host}:8080/api/base-items`;
|
||||
|
||||
const method = currentCategory?.id ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentCategory)
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsModalOpen(false);
|
||||
fetchCategories();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCategories = categories.filter(cat =>
|
||||
cat.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8 h-full overflow-y-auto custom-scrollbar bg-slate-50">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Food Categories</h1>
|
||||
<p className="text-slate-400 text-xs font-bold uppercase tracking-[0.2em] mt-2">Manage your menu structure</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentCategory({ name: '', description: '', active: true });
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="bg-primary text-white px-10 py-4 rounded-2xl font-black text-xs uppercase tracking-widest flex items-center gap-3 shadow-2xl shadow-primary/30 hover:scale-[1.02] active:scale-95 transition-all"
|
||||
>
|
||||
<Plus size={18} /> Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<StatCard icon={Layers} label="Active Categories" value={categories.filter(c => c.active).length} color="bg-primary/5 text-primary" />
|
||||
<StatCard icon={Package} label="Total Structure" value={categories.length} color="bg-emerald-50 text-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-[3rem] shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden">
|
||||
<div className="p-6 relative group">
|
||||
<Search className="absolute left-12 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-primary transition-colors" size={24} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search categories by name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-100/50 rounded-2xl py-6 pl-16 pr-8 text-sm font-semibold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
<div className="col-span-full h-40 flex items-center justify-center">
|
||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : filteredCategories.map((cat) => (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
key={cat.id}
|
||||
className="bg-white p-8 rounded-[2.8rem] border border-slate-100 shadow-sm hover:shadow-2xl transition-all group flex flex-col gap-8 relative overflow-hidden"
|
||||
>
|
||||
<div className={`absolute top-0 right-0 w-32 h-32 -mr-16 -mt-16 rounded-full opacity-[0.03] transition-transform duration-700 group-hover:scale-150 ${cat.active ? 'bg-emerald-500' : 'bg-slate-500'}`} />
|
||||
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center transition-all ${cat.active ? 'bg-primary/5 text-primary' : 'bg-slate-100 text-slate-400'} group-hover:bg-primary group-hover:text-white`}>
|
||||
<Layers size={32} />
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-all transform translate-x-4 group-hover:translate-x-0">
|
||||
<button
|
||||
onClick={() => window.location.href = `/products?category=${cat.name}`}
|
||||
className="w-10 h-10 bg-white border border-slate-100 rounded-xl flex items-center justify-center text-slate-400 hover:text-emerald-500 hover:border-emerald-500/30 shadow-sm transition-all"
|
||||
title="View Products"
|
||||
>
|
||||
<Package size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentCategory(cat);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="w-10 h-10 bg-white border border-slate-100 rounded-xl flex items-center justify-center text-slate-400 hover:text-primary hover:border-primary/30 shadow-sm transition-all"
|
||||
title="Edit Category"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight leading-none mb-3">{cat.name}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2 h-2 rounded-full ${cat.active ? 'bg-emerald-500 animate-pulse' : 'bg-slate-300'}`} />
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{cat.active ? 'Active' : 'Inactive'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CRUD Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="relative bg-white w-full max-w-lg rounded-[3rem] shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-10 border-b border-slate-100 flex items-center justify-between bg-white">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-slate-900">{currentCategory?.id ? 'Edit Category' : 'New Category'}</h2>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">Configure category properties</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-rose-500 transition-all"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-10 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] ml-2">Category Name</label>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
type="text"
|
||||
value={currentCategory?.name || ''}
|
||||
onChange={(e) => setCurrentCategory({...currentCategory, name: e.target.value})}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl py-5 px-8 text-sm font-semibold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all"
|
||||
placeholder="e.g. FAST FOOD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] ml-2">Description</label>
|
||||
<textarea
|
||||
value={currentCategory?.description || ''}
|
||||
onChange={(e) => setCurrentCategory({...currentCategory, description: e.target.value})}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl py-5 px-8 text-sm font-semibold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all h-32 resize-none"
|
||||
placeholder="Tell us more about this category..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-slate-50 p-6 rounded-3xl border border-slate-100">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-black text-slate-800">Status</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Enable or disable category visibility</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentCategory({...currentCategory, active: !currentCategory.active})}
|
||||
className={`w-16 h-8 rounded-full transition-all relative ${currentCategory?.active ? 'bg-primary' : 'bg-slate-300'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-6 h-6 bg-white rounded-full transition-all ${currentCategory?.active ? 'right-1' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="flex-1 py-5 rounded-2xl font-black text-xs uppercase tracking-widest text-slate-400 hover:bg-slate-50 transition-all border border-transparent hover:border-slate-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-primary text-white py-5 rounded-2xl font-black text-xs uppercase tracking-widest shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check size={18} /> {currentCategory?.id ? 'Update Category' : 'Save Category'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard = ({ icon: Icon, label, value, color }: any) => (
|
||||
<div className="bg-white p-8 rounded-[2.8rem] border border-slate-100 shadow-sm flex items-center gap-8 group hover:shadow-2xl transition-all">
|
||||
<div className={`w-16 h-16 rounded-[1.5rem] flex items-center justify-center group-hover:bg-primary group-hover:text-white transition-all ${color.split(' ')[0]} ${color.split(' ')[1]}`}>
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-black text-slate-900 tracking-tighter">{value}</p>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Categories;
|
||||
163
counter-frontend/src/pages/Login.tsx
Normal file
163
counter-frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
User,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CollegeLogo from '../assets/college-logo.png';
|
||||
import RitChennaiLogo from '../assets/ritchennai.webp';
|
||||
|
||||
const Login = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.removeItem('isCounterLoggedIn');
|
||||
sessionStorage.removeItem('counterUserName');
|
||||
}, []);
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (username === 'krishna' && password === '12345678') {
|
||||
sessionStorage.setItem('isCounterLoggedIn', 'true');
|
||||
sessionStorage.setItem('counterUserName', 'Krishna');
|
||||
navigate('/pos');
|
||||
} else {
|
||||
alert('Invalid credentials');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] flex flex-col md:flex-row overflow-hidden font-inter">
|
||||
{/* Left Section: Login Form */}
|
||||
<div className="w-full md:w-[45%] lg:w-[40%] flex flex-col p-8 md:p-12 lg:p-16 relative z-10 bg-white shadow-2xl">
|
||||
{/* Top Logo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-10"
|
||||
>
|
||||
<img src={CollegeLogo} alt="College Logo" className="h-16 md:h-20 object-contain" />
|
||||
</motion.div>
|
||||
|
||||
{/* Login Container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex-1 flex flex-col justify-center max-w-sm mx-auto w-full"
|
||||
>
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-[10px] font-black tracking-[0.3em] text-[#1e293b] uppercase mb-2">
|
||||
Counter Management System
|
||||
</h2>
|
||||
<div className="w-12 h-1 bg-primary mx-auto rounded-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-[#94a3b8] uppercase tracking-widest pl-1">
|
||||
Operator ID
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-[#94a3b8] group-focus-within:text-primary transition-colors">
|
||||
<User size={18} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="w-full bg-white border-2 border-[#e2e8f0] focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-sm font-semibold outline-none transition-all placeholder:text-[#cbd5e1]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center pl-1">
|
||||
<label className="text-[10px] font-black text-[#94a3b8] uppercase tracking-widest">
|
||||
Passcode
|
||||
</label>
|
||||
<button type="button" className="text-[10px] font-black text-primary uppercase hover:underline">
|
||||
Forgot?
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-[#94a3b8] group-focus-within:text-primary transition-colors">
|
||||
<Lock size={18} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-white border-2 border-[#e2e8f0] focus:border-primary rounded-2xl py-4 pl-12 pr-12 text-sm font-semibold outline-none transition-all placeholder:text-[#cbd5e1]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-[#94a3b8] hover:text-[#64748b] transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-primary text-white rounded-2xl py-4 font-black text-sm flex items-center justify-center gap-3 shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-[0.98] transition-all overflow-hidden group relative"
|
||||
>
|
||||
<span className="relative z-10">CONNECT TO POS</span>
|
||||
<ArrowRight size={18} className="relative z-10 group-hover:translate-x-1 transition-transform" />
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white/10"
|
||||
initial={{ x: '-100%' }}
|
||||
whileHover={{ x: '100%' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto text-center">
|
||||
<p className="text-[10px] font-black text-[#cbd5e1] uppercase tracking-[0.2em]">
|
||||
© 2026 RITCHENNAI • COUNTER SYSTEM
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section: Visual (Building Photo) */}
|
||||
<div className="hidden md:flex flex-1 relative bg-[#f8fafc] items-center justify-center p-8 overflow-hidden">
|
||||
<div className="absolute top-20 right-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-20 left-40 w-96 h-96 bg-primary/5 rounded-full blur-3xl"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, x: 50 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="w-full h-full relative z-10"
|
||||
>
|
||||
<div className="w-full h-full overflow-hidden shadow-2xl border-[12px] border-white bg-white">
|
||||
<img
|
||||
src={RitChennaiLogo}
|
||||
alt="RIT Chennai Building"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-primary/20 to-transparent"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
251
counter-frontend/src/pages/Orders.tsx
Normal file
251
counter-frontend/src/pages/Orders.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
CreditCard,
|
||||
User,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const MOCK_ORDERS = [
|
||||
{ id: 13, displayId: '#13', date: '2026-04-08', amount: 320, status: 'PAID', items: 3 },
|
||||
{ id: 12, displayId: '#12', date: '2026-04-08', amount: 1000, status: 'PAID', items: 1 },
|
||||
{ id: 11, displayId: '#11', date: '2026-04-08', amount: 60, status: 'PAID', items: 2 },
|
||||
{ id: 10, displayId: '#10', date: '2026-04-08', amount: 50, status: 'PAID', items: 1 },
|
||||
{ id: 9, displayId: '#9', date: '2026-04-08', amount: 50, status: 'PAID', items: 1 },
|
||||
{ id: 8, displayId: '#8', date: '2026-04-08', amount: 20, status: 'PAID', items: 1 },
|
||||
];
|
||||
|
||||
const Orders = () => {
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedOrder, setSelectedOrder] = useState<any>(null);
|
||||
const [activeTab, setActiveTab] = useState('ALL');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const fetchOrders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const paymentType = activeTab === 'ALL' ? '' : activeTab;
|
||||
|
||||
let url = `http://${host}:8080/api/orders/all?archived=false&paymentType=${paymentType}`;
|
||||
|
||||
if (searchTerm) {
|
||||
url += `&search=${encodeURIComponent(searchTerm)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
const orderList = Array.isArray(data) ? data : (data.content || []);
|
||||
setOrders(orderList);
|
||||
|
||||
if (orderList.length > 0 && !selectedOrder) {
|
||||
setSelectedOrder(orderList[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-50 overflow-hidden">
|
||||
{/* Sub Header / Tabs */}
|
||||
<div className="bg-white border-b border-slate-200 px-8 flex items-center justify-between shadow-sm shrink-0">
|
||||
<div className="flex gap-10">
|
||||
{['ALL', 'CASH', 'UPI', 'CARD'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
className={`py-6 text-xs font-black uppercase tracking-[0.2em] relative transition-all flex items-center gap-2 ${
|
||||
activeTab === tab ? 'text-primary' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && <motion.div layoutId="activeOrderTab" className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-t-full" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar: Order List */}
|
||||
<div className="w-[400px] bg-white border-r border-slate-200 flex flex-col">
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
|
||||
<h2 className="text-xs font-black text-slate-400 uppercase tracking-widest">Order Feed</h2>
|
||||
<button onClick={fetchOrders} className="text-primary hover:rotate-180 transition-all duration-500">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
|
||||
{loading ? (
|
||||
<div className="h-40 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : orders.length > 0 ? (
|
||||
orders.map((order) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
key={order.id}
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className={`p-6 rounded-[2rem] border-2 transition-all cursor-pointer group ${
|
||||
selectedOrder?.id === order.id
|
||||
? 'bg-primary/5 border-primary shadow-xl shadow-primary/5 scale-[1.02]'
|
||||
: 'bg-white border-slate-100/50 hover:border-primary/20 hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-black text-slate-900 group-hover:text-primary transition-colors text-lg">#{order.displayOrderId || order.id}</h3>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{new Date(order.createdAt).toLocaleTimeString()}</p>
|
||||
</div>
|
||||
<span className="text-xl font-black text-slate-900 tracking-tighter">₹{order.totalAmount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`px-3 py-1 rounded-lg text-[9px] font-black uppercase tracking-widest ${order.status === 'PAID' ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-xl bg-slate-50 flex items-center justify-center text-slate-400 group-hover:bg-primary group-hover:text-white transition-all">
|
||||
<ChevronRight size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-slate-300 gap-4 opacity-50">
|
||||
<ShoppingBag size={48} />
|
||||
<p className="font-black uppercase tracking-widest text-[10px]">No orders found in {activeTab}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section: Order Detail */}
|
||||
<div className="flex-1 overflow-y-auto p-12 custom-scrollbar bg-white">
|
||||
{selectedOrder ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-12">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-20 h-20 bg-primary/5 rounded-[2rem] flex items-center justify-center text-primary border border-primary/10">
|
||||
<ShoppingBag size={40} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-4xl font-black text-slate-900 tracking-tight">Order #{selectedOrder.displayOrderId || selectedOrder.id}</h1>
|
||||
<span className="bg-emerald-50 text-emerald-600 px-6 py-2 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] border border-emerald-100 shadow-sm">{selectedOrder.status}</span>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-2 opacity-60">Reference ID: {selectedOrder.orderNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-1">Created At</p>
|
||||
<p className="text-sm font-black text-slate-900">{new Date(selectedOrder.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="bg-slate-50/50 p-8 rounded-[2.5rem] border border-slate-100/50 space-y-3">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Payment Type</p>
|
||||
<p className="text-md font-black text-slate-800 flex items-center gap-3 uppercase tracking-tight">
|
||||
<CreditCard size={20} className="text-primary" /> {selectedOrder.paymentMethod}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50/50 p-8 rounded-[2.5rem] border border-slate-100/50 space-y-3">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Customer</p>
|
||||
<p className="text-md font-black text-slate-800 flex items-center gap-3">
|
||||
<User size={20} className="text-primary" /> {selectedOrder.user?.name || 'Walk-in Customer'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50/50 p-8 rounded-[2.5rem] border border-slate-100/50 space-y-3">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Order Type</p>
|
||||
<p className="text-md font-black text-slate-800 flex items-center gap-3 uppercase tracking-widest">
|
||||
{selectedOrder.orderType.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-12">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-[0.2em]">Item List</h3>
|
||||
<span className="text-xs font-bold text-slate-400 leading-none">{selectedOrder.items?.length || 0} ITEMS</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{selectedOrder.items?.map((item: any, idx: number) => (
|
||||
<div key={idx} className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm flex items-center justify-between group hover:border-primary/20 transition-all">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center font-black text-slate-400 text-sm group-hover:bg-primary/5 group-hover:text-primary transition-all">
|
||||
{item.productName?.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black text-slate-800 uppercase tracking-tight text-md">{item.productName}</h4>
|
||||
<p className="text-[10px] font-bold text-slate-400 mt-1 uppercase tracking-widest">{item.stallName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-black text-slate-900 tracking-tighter">₹{item.price * item.quantity}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 mt-0.5 uppercase tracking-widest">{item.quantity}x @ ₹{item.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-8 border-t border-slate-100">
|
||||
<div className="bg-primary p-1 rounded-[3rem] shadow-2xl shadow-primary/30 min-w-[400px]">
|
||||
<div className="bg-white p-10 rounded-[2.8rem] space-y-6">
|
||||
<div className="flex justify-between items-center opacity-40">
|
||||
<span className="text-xs font-black uppercase tracking-widest">Subtotal</span>
|
||||
<span className="text-sm font-black tracking-tight">₹{selectedOrder.totalAmount}</span>
|
||||
</div>
|
||||
<div className="h-[1px] bg-slate-100" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-black text-slate-900 tracking-tight uppercase">Order Total</span>
|
||||
<span className="text-4xl font-black text-primary tracking-tighter">₹{selectedOrder.totalAmount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-200 gap-6 opacity-40">
|
||||
<div className="w-24 h-24 bg-slate-50 rounded-[2.5rem] flex items-center justify-center">
|
||||
<ShoppingBag size={48} />
|
||||
</div>
|
||||
<p className="font-black uppercase tracking-[0.3em] text-[10px]">Select an order to view details</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
` }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Orders;
|
||||
352
counter-frontend/src/pages/POS.tsx
Normal file
352
counter-frontend/src/pages/POS.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
ShoppingBag,
|
||||
Trash2,
|
||||
Plus,
|
||||
Minus,
|
||||
UtensilsCrossed,
|
||||
Coffee,
|
||||
IceCream,
|
||||
Pizza,
|
||||
Cake,
|
||||
Phone,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
category: string;
|
||||
imageData?: string;
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const cat = category.toUpperCase();
|
||||
if (cat.includes('ICE')) return IceCream;
|
||||
if (cat.includes('JUICE') || cat.includes('DRINK')) return UtensilsCrossed;
|
||||
if (cat.includes('COFFEE') || cat.includes('TEA') || cat.includes('BISCUIT')) return Coffee;
|
||||
if (cat.includes('PIZZA') || cat.includes('SNACK')) return Pizza;
|
||||
if (cat.includes('CAKE') || cat.includes('SWEET') || cat.includes('CHOCO')) return Cake;
|
||||
return UtensilsCrossed;
|
||||
};
|
||||
|
||||
const POS = () => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>(['ALL']);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCategory, setActiveCategory] = useState('ALL');
|
||||
const [cart, setCart] = useState<any[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
}, []);
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const host = window.location.hostname;
|
||||
const [prodRes, catRes] = await Promise.all([
|
||||
fetch(`http://${host}:8080/api/products?size=1000`),
|
||||
fetch(`http://${host}:8080/api/products/categories`)
|
||||
]);
|
||||
|
||||
if (prodRes.ok) {
|
||||
const data = await prodRes.json();
|
||||
setProducts(data.content || []);
|
||||
}
|
||||
|
||||
if (catRes.ok) {
|
||||
const data = await catRes.json();
|
||||
setCategories(['ALL', ...data]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter(p => {
|
||||
if (!p) return false;
|
||||
const search = (searchQuery || '').toLowerCase();
|
||||
const name = (p.name || '').toLowerCase();
|
||||
const matchesSearch = name.includes(search);
|
||||
const matchesCategory = activeCategory === 'ALL' || p.category === activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [products, searchQuery, activeCategory]);
|
||||
|
||||
const addToCart = (product: Product) => {
|
||||
const existing = cart.find(item => item.id === product.id);
|
||||
if (existing) {
|
||||
setCart(cart.map(item =>
|
||||
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
|
||||
));
|
||||
} else {
|
||||
setCart([...cart, { ...product, quantity: 1 }]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateQuantity = (id: number, delta: number) => {
|
||||
setCart(cart.map(item => {
|
||||
if (item.id === id) {
|
||||
const newQty = Math.max(1, item.quantity + delta);
|
||||
return { ...item, quantity: newQty };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
};
|
||||
|
||||
const removeFromCart = (id: number) => {
|
||||
setCart(cart.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const total = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
|
||||
const handleCompleteOrder = async () => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const orderData = {
|
||||
userId: 1, // Using test user ID
|
||||
totalAmount: total,
|
||||
paymentMethod: "CASH", // Default to CASH for now
|
||||
status: "COMPLETED",
|
||||
items: cart.map(item => ({
|
||||
productId: item.id,
|
||||
productName: item.name,
|
||||
price: item.price,
|
||||
quantity: item.quantity
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await fetch(`http://${host}:8080/api/orders`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(orderData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("Order placed successfully! Stock has been updated.");
|
||||
setCart([]);
|
||||
fetchInitialData();
|
||||
} else {
|
||||
let errorMessage = "Unknown error";
|
||||
try {
|
||||
const err = await response.json();
|
||||
errorMessage = err.error || err.message || JSON.stringify(err);
|
||||
} catch (e) {
|
||||
errorMessage = await response.text() || response.statusText;
|
||||
}
|
||||
alert(`Failed to place order: ${errorMessage}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Order error:", error);
|
||||
alert("Network error while placing order.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex overflow-hidden">
|
||||
{/* Left Section: Menu */}
|
||||
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
|
||||
{/* Categories Bar */}
|
||||
<div className="p-4 bg-white border-b border-slate-200 overflow-x-auto no-scrollbar">
|
||||
<div className="flex gap-4">
|
||||
{categories.map((catName) => {
|
||||
const Icon = getCategoryIcon(catName);
|
||||
return (
|
||||
<button
|
||||
key={catName}
|
||||
onClick={() => setActiveCategory(catName)}
|
||||
className={`flex items-center gap-2 px-6 py-3 rounded-xl text-xs font-black transition-all border-2 whitespace-nowrap ${
|
||||
activeCategory === catName
|
||||
? 'bg-primary text-white border-primary shadow-lg shadow-primary/20'
|
||||
: 'bg-white text-slate-400 border-indigo-50 hover:border-primary/30'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{catName}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Products Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6 custom-scrollbar">
|
||||
<div className="relative group max-w-2xl">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-primary transition-colors" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Product..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 bg-white border border-slate-200 rounded-2xl text-sm font-semibold outline-none focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-primary" size={48} />
|
||||
</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400">
|
||||
<ShoppingBag size={64} className="mb-4 opacity-20" />
|
||||
<p className="font-bold">No products found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{filteredProducts.map((product) => (
|
||||
<motion.div
|
||||
whileHover={{ y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
key={product.id}
|
||||
onClick={() => addToCart(product)}
|
||||
className="bg-white rounded-[2rem] p-5 border border-slate-100 shadow-sm hover:shadow-xl hover:border-primary/20 transition-all cursor-pointer group flex flex-col relative"
|
||||
>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<span className={`px-2.5 py-1 rounded-lg text-[10px] font-black border uppercase tracking-wider ${
|
||||
(product.stock || 0) < 20 ? 'bg-rose-50 text-rose-500 border-rose-100' : 'bg-emerald-50 text-emerald-500 border-emerald-100'
|
||||
}`}>
|
||||
{product.stock || 0} left
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square bg-slate-50 rounded-2xl mb-4 flex items-center justify-center text-slate-200 group-hover:bg-primary/5 transition-colors overflow-hidden">
|
||||
{product.imageData ? (
|
||||
<img src={product.imageData} alt={product.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<ShoppingBag size={48} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xs font-black text-slate-800 leading-snug mb-1 line-clamp-2 uppercase">
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className="mt-auto">
|
||||
<p className="text-lg font-black text-primary">₹{product.price}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section: Current Order */}
|
||||
<div className="w-[450px] bg-white border-l border-slate-200 flex flex-col shadow-[-10px_0_30px_rgba(0,0,0,0.02)]">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight">Current Order</h2>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest flex items-center gap-1.5 mt-0.5">
|
||||
<Phone size={10} className="text-primary" /> +91 9043941910
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-4 py-2 bg-slate-50 text-slate-600 rounded-xl text-[10px] font-black uppercase hover:bg-slate-100 transition-colors">View Orders</button>
|
||||
<span className="bg-primary text-white text-[10px] font-black px-3 py-1.5 rounded-lg uppercase shadow-lg shadow-primary/20">{cart.length} items</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4 custom-scrollbar">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{cart.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-400 gap-4">
|
||||
<div className="w-20 h-20 bg-slate-50 rounded-3xl flex items-center justify-center">
|
||||
<ShoppingBag size={40} className="text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-slate-600">Cart is empty</h3>
|
||||
<p className="text-xs font-medium">Select products to start billing</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
cart.map((item) => (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
key={item.id}
|
||||
className="bg-[#ebfaf4] border border-[#d1f4e6] rounded-[2rem] p-5 relative group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1 pr-12">
|
||||
<h4 className="text-[11px] font-black text-slate-800 uppercase tracking-tight leading-tight">{item.name}</h4>
|
||||
<p className="text-[10px] font-bold text-[#42ab7e] mt-1 uppercase">₹{item.price} each</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="w-9 h-9 bg-white text-rose-500 rounded-xl flex items-center justify-center shadow-sm border border-rose-50 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center bg-white rounded-xl p-1 shadow-sm border border-slate-50">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, -1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-slate-400 hover:text-primary transition-colors"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="w-10 text-center font-black text-sm text-slate-900">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-slate-400 hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black text-slate-900 tracking-tight">₹{item.price * item.quantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="p-8 bg-slate-50 border-t border-slate-200 shadow-[0_-10px_40px_rgba(0,0,0,0.03)] rounded-t-[3rem]">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<span className="text-lg font-black text-slate-900">Total:</span>
|
||||
<span className="text-4xl font-black text-primary tracking-tighter">₹{total}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={cart.length === 0}
|
||||
onClick={handleCompleteOrder}
|
||||
className="w-full bg-primary text-white py-5 rounded-[2rem] font-black text-sm uppercase tracking-[0.2em] shadow-2xl shadow-primary/20 hover:scale-[1.02] active:scale-[0.98] transition-all disabled:opacity-50 disabled:scale-100 disabled:shadow-none"
|
||||
>
|
||||
Complete Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 5px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
|
||||
` }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default POS;
|
||||
412
counter-frontend/src/pages/Products.tsx
Normal file
412
counter-frontend/src/pages/Products.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Database,
|
||||
ShoppingBag,
|
||||
Package,
|
||||
Clock,
|
||||
Check,
|
||||
Power,
|
||||
PowerOff,
|
||||
Tag,
|
||||
Barcode,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Pagination from '../components/Pagination';
|
||||
|
||||
interface ProductSession {
|
||||
id?: number;
|
||||
dayOfWeek: string;
|
||||
active: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id?: number;
|
||||
productId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
basePrice: number;
|
||||
price: number;
|
||||
offerPrice: number;
|
||||
discountPercent: number;
|
||||
discountAmount: number;
|
||||
counter: string;
|
||||
tag: string;
|
||||
parcelCharges: number;
|
||||
barcode: string;
|
||||
attributesOptional: boolean;
|
||||
veg: boolean;
|
||||
hasAllergy: boolean;
|
||||
parcelNotAllowed: boolean;
|
||||
sessionOptional: boolean;
|
||||
sessions: ProductSession[];
|
||||
imageData: string;
|
||||
active: boolean;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
const DAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||
|
||||
const getDefaultSessions = (): ProductSession[] =>
|
||||
DAYS.map(day => ({ dayOfWeek: day, active: true, startTime: '00:00', endTime: '23:59' }));
|
||||
|
||||
const emptyProduct: Product = {
|
||||
productId: '',
|
||||
name: '',
|
||||
category: '',
|
||||
description: '',
|
||||
basePrice: 0,
|
||||
price: 0,
|
||||
offerPrice: 0,
|
||||
discountPercent: 0,
|
||||
discountAmount: 0,
|
||||
counter: '',
|
||||
tag: '',
|
||||
parcelCharges: 0,
|
||||
barcode: '',
|
||||
attributesOptional: false,
|
||||
veg: false,
|
||||
hasAllergy: false,
|
||||
parcelNotAllowed: false,
|
||||
sessionOptional: false,
|
||||
sessions: getDefaultSessions(),
|
||||
imageData: '',
|
||||
active: true,
|
||||
stock: 0
|
||||
};
|
||||
|
||||
const Products = () => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [formData, setFormData] = useState<Product>(emptyProduct);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalElements, setTotalElements] = useState(0);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const catParam = params.get('category');
|
||||
if (catParam) {
|
||||
setSearchTerm(catParam);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
fetchCategories();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/products?page=${currentPage}&size=${pageSize}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.content) {
|
||||
setProducts(data.content);
|
||||
setTotalElements(data.totalElements);
|
||||
} else if (Array.isArray(data)) {
|
||||
setProducts(data);
|
||||
setTotalElements(data.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/base-items`);
|
||||
const data = await response.json();
|
||||
setCategories(Array.isArray(data) ? data : (data.content || []));
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const host = window.location.hostname;
|
||||
const url = editingProduct?.id
|
||||
? `http://${host}:8080/api/products/${editingProduct.id}`
|
||||
: `http://${host}:8080/api/products`;
|
||||
const method = editingProduct?.id ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsModalOpen(false);
|
||||
fetchProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving product:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (product: Product) => {
|
||||
if (!window.confirm(`Delete ${product.name}?`)) return;
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/products/${product.id}`, { method: 'DELETE' });
|
||||
if (response.ok) fetchProducts();
|
||||
} catch (error) {
|
||||
console.error('Error deleting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStock = async (product: Product) => {
|
||||
try {
|
||||
const host = window.location.hostname;
|
||||
const response = await fetch(`http://${host}:8080/api/products/${product.id}/toggle-stock`, { method: 'PATCH' });
|
||||
if (response.ok) fetchProducts();
|
||||
} catch (error) {
|
||||
console.error('Error toggling stock:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => setFormData({ ...formData, imageData: reader.result as string });
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(p =>
|
||||
(p.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(p.category || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-slate-50 overflow-hidden font-inter">
|
||||
{/* Search & Header */}
|
||||
<div className="px-8 py-6 bg-white border-b border-slate-100 flex flex-col md:flex-row items-center justify-between gap-6 shrink-0 shadow-sm relative z-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-slate-900 tracking-tight leading-none mb-1">Products</h1>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Manage catalog & stock details</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-2xl px-6 py-3 bg-slate-50 border border-slate-100 rounded-[2rem] flex items-center gap-4 group focus-within:bg-white focus-within:ring-4 focus-within:ring-primary/5 transition-all">
|
||||
<Search size={22} className="text-slate-400 group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products by name or category..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-transparent border-none outline-none text-sm font-semibold text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingProduct(null);
|
||||
setFormData(emptyProduct);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="bg-primary text-white px-10 py-4 rounded-[2rem] font-black text-xs uppercase tracking-widest flex items-center gap-3 shadow-2xl shadow-primary/30 hover:scale-[1.02] active:scale-95 transition-all w-full md:w-auto justify-center"
|
||||
>
|
||||
<Plus size={18} /> Add Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||
{loading ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin"></div>
|
||||
<p className="text-xs font-black text-slate-400 uppercase tracking-widest">Loading Catalog...</p>
|
||||
</div>
|
||||
) : filteredProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredProducts.map((prod) => (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
key={prod.id}
|
||||
className="bg-white rounded-[3rem] border border-slate-100 shadow-sm hover:shadow-2xl transition-all group flex flex-col overflow-hidden relative"
|
||||
>
|
||||
<div className="aspect-[4/3] bg-slate-50 relative overflow-hidden group-hover:bg-primary/5 transition-colors">
|
||||
{prod.imageData ? (
|
||||
<img src={prod.imageData} alt={prod.name} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-200 group-hover:text-primary/20 transition-all">
|
||||
<ShoppingBag size={80} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-6 left-6 flex flex-col gap-2">
|
||||
<span className="bg-white/90 backdrop-blur px-3 py-1 rounded-xl text-[9px] font-black uppercase text-primary border border-primary/10 shadow-sm">₹{prod.price}</span>
|
||||
</div>
|
||||
<div className="absolute top-6 right-6 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-all translate-x-4 group-hover:translate-x-0">
|
||||
<button
|
||||
onClick={() => { setEditingProduct(prod); setFormData(prod); setIsModalOpen(true); }}
|
||||
className="w-10 h-10 bg-white rounded-xl flex items-center justify-center text-slate-400 hover:text-primary shadow-xl transition-all"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(prod)}
|
||||
className="w-10 h-10 bg-white rounded-xl flex items-center justify-center text-slate-400 hover:text-rose-500 shadow-xl transition-all"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex flex-col gap-6 flex-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="bg-primary/5 text-primary px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-widest">{prod.category}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-widest ${prod.stock > 0 ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'}`}>
|
||||
{prod.stock > 0 ? `Stock: ${prod.stock}` : 'Out of Stock'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-slate-900 uppercase tracking-tight leading-tight group-hover:text-primary transition-colors">{prod.name}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-auto pt-4 border-t border-slate-50">
|
||||
<button
|
||||
onClick={() => handleToggleStock(prod)}
|
||||
className={`flex-1 py-3 px-4 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all ${
|
||||
prod.stock > 0
|
||||
? 'bg-rose-50 text-rose-600 hover:bg-rose-600 hover:text-white'
|
||||
: 'bg-emerald-50 text-emerald-600 hover:bg-emerald-600 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Database size={12} /> {prod.stock > 0 ? 'Out of Stock' : 'In Stock'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-6 opacity-40">
|
||||
<div className="w-24 h-24 bg-slate-50 rounded-[3rem] flex items-center justify-center">
|
||||
<Package size={48} />
|
||||
</div>
|
||||
<p className="font-black uppercase tracking-[0.3em] text-[10px]">No products matched your search</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
totalElements={totalElements}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(newSize) => { setPageSize(newSize); setCurrentPage(0); }}
|
||||
/>
|
||||
|
||||
{/* CRUD Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-8">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} onClick={() => setIsModalOpen(false)} className="absolute inset-0 bg-slate-900/60 backdrop-blur-md" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative bg-white w-full max-w-5xl rounded-[3rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-10 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-slate-900">{editingProduct ? 'Edit Product' : 'Add New Product'}</h2>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">Configure catalog & inventory data</p>
|
||||
</div>
|
||||
<button onClick={() => setIsModalOpen(false)} className="w-14 h-14 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-rose-500 transition-all"><X size={32} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-10 overflow-y-auto custom-scrollbar space-y-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="space-y-8">
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4 mb-2 block">Product Identity</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input required type="text" placeholder="Prod Name*" value={formData.name} onChange={(e) => setFormData({...formData, name: e.target.value})} className="col-span-2 bg-slate-50 border border-slate-100 rounded-2xl py-5 px-8 text-sm font-semibold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all" />
|
||||
<input type="text" placeholder="Prod ID" value={formData.productId} onChange={(e) => setFormData({...formData, productId: e.target.value})} className="bg-slate-50 border border-slate-100 rounded-2xl py-5 px-8 text-sm font-semibold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all" />
|
||||
<select required value={formData.category} onChange={(e) => setFormData({...formData, category: e.target.value})} className="bg-slate-50 border border-slate-100 rounded-2xl py-5 px-8 text-sm font-semibold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all appearance-none cursor-pointer">
|
||||
<option value="">Category*</option>
|
||||
{categories.map(c => <option key={c.id} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4 mb-2 block">Pricing & Value</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 font-bold">₹</span>
|
||||
<input type="number" placeholder="MRP Price" value={formData.price} onChange={(e) => setFormData({...formData, price: parseFloat(e.target.value) || 0})} className="w-full bg-slate-50 border border-slate-100 rounded-2xl py-5 pl-10 pr-8 text-sm font-black outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all text-primary" />
|
||||
</div>
|
||||
<input type="number" placeholder="Inventory Count" value={formData.stock} onChange={(e) => setFormData({...formData, stock: parseInt(e.target.value) || 0})} className="bg-slate-50 border border-slate-100 rounded-2xl py-5 px-8 text-sm font-bold outline-none focus:bg-white focus:ring-4 focus:ring-primary/5 focus:border-primary transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="group">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4 mb-2 block">Product Visual</label>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="h-48 bg-slate-50 border-4 border-dashed border-slate-100 rounded-[3rem] flex flex-col items-center justify-center text-slate-300 hover:border-primary/20 hover:bg-primary/5 transition-all cursor-pointer overflow-hidden relative"
|
||||
>
|
||||
{formData.imageData ? (
|
||||
<img src={formData.imageData} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon size={48} className="mb-2" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Select Image</span>
|
||||
</>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleImageChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<button type="button" onClick={() => setFormData({...formData, veg: !formData.veg})} className={`flex-1 py-4 px-6 rounded-2xl text-[10px] font-black uppercase tracking-widest border transition-all ${formData.veg ? 'bg-emerald-50 border-emerald-100 text-emerald-600' : 'bg-slate-50 border-slate-100 text-slate-300'}`}>VEG ONLY</button>
|
||||
<button type="button" onClick={() => setFormData({...formData, active: !formData.active})} className={`flex-1 py-4 px-6 rounded-2xl text-[10px] font-black uppercase tracking-widest border transition-all ${formData.active ? 'bg-indigo-50 border-indigo-100 text-indigo-600' : 'bg-slate-50 border-slate-100 text-slate-300'}`}>PUBLISHED</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-10 sticky bottom-0 bg-white">
|
||||
<button type="button" onClick={() => setIsModalOpen(false)} className="flex-1 py-6 rounded-[2rem] font-black text-xs uppercase tracking-widest text-slate-400 hover:bg-slate-50 transition-all border border-transparent hover:border-slate-100">Discard Changes</button>
|
||||
<button type="submit" className="flex-[2] bg-primary text-white py-6 rounded-[2rem] font-black text-xs uppercase tracking-widest shadow-2xl shadow-primary/30 hover:scale-[1.02] active:scale-95 transition-all flex items-center justify-center gap-2"><Check size={20} /> {editingProduct ? 'Save Updates' : 'Publish Product'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
` }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
1
counter-frontend/src/vite-env.d.ts
vendored
Normal file
1
counter-frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
counter-frontend/tailwind.config.js
Normal file
20
counter-frontend/tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "#231651",
|
||||
secondary: "#64748b",
|
||||
background: "#f8fafc",
|
||||
},
|
||||
fontFamily: {
|
||||
inter: ['Inter', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
counter-frontend/tsconfig.json
Normal file
24
counter-frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
counter-frontend/vite.config.ts
Normal file
11
counter-frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user