initailization on the POS
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Nearle Daily — POS Terminal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2934
package-lock.json
generated
Normal file
2934
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "nearle-pos",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.470.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
29
remove_hover.js
Normal file
29
remove_hover.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function walk(dir) {
|
||||
let results = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
list.forEach(function(file) {
|
||||
file = dir + '/' + file;
|
||||
const stat = fs.statSync(file);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(walk(file));
|
||||
} else {
|
||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
||||
results.push(file);
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
const files = walk('./src');
|
||||
files.forEach(file => {
|
||||
let content = fs.readFileSync(file, 'utf8');
|
||||
if (content.includes('hover:')) {
|
||||
const newContent = content.replace(/hover:[\w\-\/]+/g, '');
|
||||
fs.writeFileSync(file, newContent, 'utf8');
|
||||
console.log(`Updated ${file}`);
|
||||
}
|
||||
});
|
||||
34
replace_colors.js
Normal file
34
replace_colors.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const dir = path.join(__dirname, 'src', 'pages', 'pos');
|
||||
const files = ['POSPage.tsx', 'CustomerIdentifyPanel.tsx', 'PaymentModal.tsx'];
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(dir, file);
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
content = content.replace(/bg-blue-600/g, 'bg-primary');
|
||||
content = content.replace(/text-blue-600/g, 'text-primary');
|
||||
content = content.replace(/border-blue-600/g, 'border-primary');
|
||||
content = content.replace(/ring-blue-600/g, 'ring-primary');
|
||||
content = content.replace(/shadow-blue-600/g, 'shadow-primary');
|
||||
|
||||
content = content.replace(/bg-blue-700/g, 'bg-primary/90');
|
||||
content = content.replace(/text-blue-700/g, 'text-primary');
|
||||
|
||||
content = content.replace(/bg-blue-800/g, 'bg-primary');
|
||||
content = content.replace(/text-blue-800/g, 'text-primary');
|
||||
|
||||
content = content.replace(/bg-blue-50/g, 'bg-primary/10');
|
||||
content = content.replace(/text-blue-500/g, 'text-primary/70');
|
||||
|
||||
content = content.replace(/bg-blue-100/g, 'bg-primary/20');
|
||||
content = content.replace(/border-blue-100/g, 'border-primary/20');
|
||||
|
||||
content = content.replace(/bg-blue-200/g, 'bg-primary/30');
|
||||
content = content.replace(/border-blue-200/g, 'border-primary/30');
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Updated ${file}`);
|
||||
});
|
||||
35
src/App.tsx
Normal file
35
src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AppLayout from './components/layout/AppLayout';
|
||||
import POSPage from './pages/pos/POSPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import ProductsPage from './pages/products/ProductsPage';
|
||||
import InventoryPage from './pages/inventory/InventoryPage';
|
||||
import CustomersPage from './pages/customers/CustomersPage';
|
||||
import PromotionsPage from './pages/promotions/PromotionsPage';
|
||||
import ReportsPage from './pages/reports/ReportsPage';
|
||||
import SuppliersPage from './pages/suppliers/SuppliersPage';
|
||||
import SettingsPage from './pages/settings/SettingsPage';
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<Navigate to="/pos" replace />} />
|
||||
<Route path="/pos" element={<POSPage />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/inventory" element={<InventoryPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
<Route path="/promotions" element={<PromotionsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/suppliers" element={<SuppliersPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
103
src/components/layout/AppLayout.tsx
Normal file
103
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, NavLink, useLocation, Navigate } from 'react-router-dom';
|
||||
import { ShoppingCart, LayoutDashboard, Package, Archive, Users, Tag, FileText, Truck, Settings } from 'lucide-react';
|
||||
import { products } from '@/data/products';
|
||||
import { purchaseOrders } from '@/data/suppliers';
|
||||
import logo from '../../../../logo.png';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/pos', label: 'POS', icon: ShoppingCart },
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/products', label: 'Products', icon: Package },
|
||||
{ path: '/inventory', label: 'Inventory', icon: Archive },
|
||||
{ path: '/customers', label: 'Customers', icon: Users },
|
||||
{ path: '/promotions', label: 'Promos', icon: Tag },
|
||||
{ path: '/reports', label: 'Reports', icon: FileText },
|
||||
{ path: '/suppliers', label: 'Suppliers', icon: Truck },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export default function AppLayout() {
|
||||
const location = useLocation();
|
||||
const currentNav = NAV_ITEMS.find(item => item.path === location.pathname);
|
||||
const { currentUser, logout } = useAuthStore();
|
||||
|
||||
// Calculate badges
|
||||
const lowStockCount = products.filter(p => p.stock <= p.reorderPoint).length;
|
||||
const pendingPoCount = purchaseOrders.filter(po => po.status === 'pending').length;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `Nearle Daily — ${currentNav?.label || 'POS'}`;
|
||||
}, [currentNav]);
|
||||
|
||||
const getBadge = (path: string) => {
|
||||
if (path === '/inventory' && lowStockCount > 0) return lowStockCount;
|
||||
if (path === '/suppliers' && pendingPoCount > 0) return pendingPoCount;
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (!currentUser) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-gray-100 overflow-hidden">
|
||||
{/* Topbar */}
|
||||
<div className="h-[52px] bg-primary text-white flex items-center px-4 justify-between shrink-0 shadow-md z-10 relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={logo} alt="Logo" className="h-10 w-auto max-w-[150px] object-contain" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div id="topbar-actions"></div>
|
||||
<div className="h-8 w-[1px] bg-white/20 mx-2" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold leading-tight">{currentUser.name}</div>
|
||||
<div className="text-[10px] text-white/70 uppercase tracking-wider font-bold">{currentUser.role}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="h-8 px-3 rounded text-xs font-bold bg-white/10 hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto w-full relative">
|
||||
<div key={location.pathname} className="h-full w-full animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Nav */}
|
||||
<div className="h-[68px] bg-primary flex items-center justify-around px-2 shrink-0 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-10 relative">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex flex-col items-center justify-center min-w-[64px] min-h-[56px] rounded-xl active:scale-95 transition-transform touch-manipulation select-none relative ${
|
||||
isActive ? 'text-primary bg-white shadow-sm' : 'text-white/70 active:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<item.icon className={`w-6 h-6 mb-1 ${isActive ? 'stroke-[2.5px]' : 'stroke-2'}`} />
|
||||
{getBadge(item.path) > 0 && (
|
||||
<span className="absolute -top-1 -right-2 bg-amber-500 text-white text-[10px] font-bold w-4 h-4 flex items-center justify-center rounded-full border border-primary z-10">
|
||||
{getBadge(item.path)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] ${isActive ? 'font-bold' : 'font-medium'}`}>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/ui/Avatar.tsx
Normal file
22
src/components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
|
||||
interface AvatarProps {
|
||||
initials: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Avatar({ initials, size = 'md', color = 'bg-primary', className = '' }: AvatarProps) {
|
||||
const sizes = {
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center rounded-full text-white font-bold select-none ${sizes[size]} ${color} ${className}`}>
|
||||
{initials.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/ui/Badge.tsx
Normal file
23
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: 'green' | 'red' | 'amber' | 'blue' | 'gray';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Badge({ variant = 'gray', children, className = '' }: BadgeProps) {
|
||||
const variants = {
|
||||
green: 'bg-green-100 text-green-700',
|
||||
red: 'bg-red-100 text-red-700',
|
||||
amber: 'bg-amber-100 text-amber-700',
|
||||
blue: 'bg-blue-100 text-blue-700',
|
||||
gray: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold uppercase tracking-wide select-none ${variants[variant]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
53
src/components/ui/Button.tsx
Normal file
53
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { ButtonHTMLAttributes } from 'react';
|
||||
import Spinner from './Spinner';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'outline' | 'danger' | 'success' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-semibold select-none touch-manipulation transition-transform active:scale-[0.97] active:opacity-90';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white border border-transparent',
|
||||
outline: 'bg-white text-gray-700 border border-gray-300',
|
||||
danger: 'bg-red-600 text-white border border-transparent',
|
||||
success: 'bg-green-600 text-white border border-transparent',
|
||||
ghost: 'bg-transparent text-gray-700 border border-transparent active:bg-gray-100',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'min-h-[40px] px-3 text-sm',
|
||||
md: 'min-h-[48px] px-5 text-base',
|
||||
lg: 'min-h-[56px] px-8 text-lg',
|
||||
};
|
||||
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variants[variant]} ${sizes[size]} ${isDisabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''} ${className}`}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="w-5 h-5 mr-2 text-current" />
|
||||
) : icon ? (
|
||||
<span className={children ? 'mr-2' : ''}>{icon}</span>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
22
src/components/ui/Card.tsx
Normal file
22
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Card({ title, action, children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden ${className}`}>
|
||||
{(title || action) && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
{title && <h3 className="font-bold text-gray-900">{title}</h3>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/ui/Input.tsx
Normal file
27
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { InputHTMLAttributes } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Input({ label, icon, error, className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||
{label && <label className="text-sm font-semibold text-gray-700 select-none">{label}</label>}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute left-3 top-0 bottom-0 flex items-center justify-center text-gray-500 pointer-events-none">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className={`w-full h-[48px] rounded-lg border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-primary focus:ring-1 focus:ring-primary'} bg-white text-base text-gray-900 outline-none transition-shadow placeholder-gray-400 ${icon ? 'pl-10' : 'pl-4'} pr-4 touch-manipulation`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className="text-xs text-red-500 font-medium select-none">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/ui/Modal.tsx
Normal file
63
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, footer }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
// We don't need body overflow hidden here because our app is fixed 1366x768,
|
||||
// but it's good practice.
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
{/* Backdrop tap zone */}
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="bg-white w-full max-w-lg rounded-2xl shadow-xl relative z-10 flex flex-col max-h-[90vh]">
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center min-w-[48px] min-h-[48px] rounded-full bg-gray-100 text-gray-500 active:scale-[0.95] touch-manipulation select-none"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 rounded-b-2xl shrink-0 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/SearchBar.tsx
Normal file
24
src/components/ui/SearchBar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SearchBar({ placeholder = 'Search...', value, onChange, className = '' }: SearchBarProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full h-10 pl-10 pr-4 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all placeholder:text-gray-400 font-medium"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/components/ui/Select.tsx
Normal file
32
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SelectHTMLAttributes } from 'react';
|
||||
|
||||
interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
options: SelectOption[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Select({ label, options, error, className = '', ...props }: SelectProps) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||
{label && <label className="text-sm font-semibold text-gray-700 select-none">{label}</label>}
|
||||
<select
|
||||
className={`w-full h-10 rounded-lg border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-primary focus:ring-1 focus:ring-primary'} bg-white text-sm text-gray-900 outline-none transition-shadow px-4 touch-manipulation appearance-none`}
|
||||
style={{ backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, backgroundPosition: 'right 0.5rem center', backgroundRepeat: 'no-repeat', backgroundSize: '1.25em 1.25em' }}
|
||||
{...props}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span className="text-xs text-red-500 font-medium select-none">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/ui/Skeleton.tsx
Normal file
5
src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export default function Skeleton({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse bg-gray-200 rounded-lg ${className}`} />
|
||||
);
|
||||
}
|
||||
10
src/components/ui/Spinner.tsx
Normal file
10
src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
|
||||
export default function Spinner({ className = 'w-6 h-6' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={`animate-spin text-primary ${className}`} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/StatCard.tsx
Normal file
33
src/components/ui/StatCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
change?: string | number;
|
||||
changeType?: 'up' | 'down';
|
||||
icon: React.ReactNode;
|
||||
iconBg?: string;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, change, changeType, icon, iconBg = 'bg-primary/10 text-primary' }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden flex flex-col p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold text-gray-500">{label}</span>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center [&>svg]:w-4 [&>svg]:h-4 ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-xl font-extrabold text-gray-900">{value}</span>
|
||||
{change && (
|
||||
<span className={`text-[10px] font-bold ${
|
||||
changeType === 'up' ? 'text-green-600' : changeType === 'down' ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{changeType === 'up' ? '↑' : changeType === 'down' ? '↓' : ''} {change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/ui/Table.tsx
Normal file
82
src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
onRowClick?: (item: T) => void;
|
||||
emptyMessage?: string;
|
||||
onSort?: (key: string) => void;
|
||||
sortConfig?: { key: string; direction: 'asc' | 'desc' } | null;
|
||||
}
|
||||
|
||||
export default function Table<T extends { id: string | number }>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
onRowClick,
|
||||
emptyMessage = 'No data found',
|
||||
onSort,
|
||||
sortConfig,
|
||||
}: TableProps<T>) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`py-2 px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider select-none ${col.sortable ? 'cursor-pointer active:text-gray-800 touch-manipulation' : ''}`}
|
||||
onClick={() => col.sortable && onSort && onSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && sortConfig?.key === col.key && (
|
||||
<span className="text-primary font-bold">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="py-8 text-center text-gray-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="py-8 text-center text-gray-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`border-b border-gray-100 last:border-0 ${onRowClick ? 'cursor-pointer active:bg-gray-50 touch-manipulation' : ''}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="py-2 px-4 min-h-[40px] text-sm text-gray-700">
|
||||
{col.render ? col.render(row) : (row as any)[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/Tabs.tsx
Normal file
33
src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function Tabs({ tabs, activeTab, onChange }: TabsProps) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto py-2" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`flex-1 py-2 px-3 rounded-md text-xs font-semibold transition-all select-none touch-manipulation active:scale-[0.97] ${
|
||||
isActive ? 'bg-primary text-white' : 'bg-white text-gray-600 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/TopbarAction.tsx
Normal file
14
src/components/ui/TopbarAction.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default function TopbarAction({ children }: { children: ReactNode }) {
|
||||
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTargetElement(document.getElementById('topbar-actions'));
|
||||
}, []);
|
||||
|
||||
if (!targetElement) return null;
|
||||
|
||||
return createPortal(children, targetElement);
|
||||
}
|
||||
14
src/components/ui/index.ts
Normal file
14
src/components/ui/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { default as Button } from './Button';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as TopbarAction } from './TopbarAction';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Select } from './Select';
|
||||
export { default as Modal } from './Modal';
|
||||
export { default as Table } from './Table';
|
||||
export { default as Skeleton } from './Skeleton';
|
||||
export { default as Card } from './Card';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as Spinner } from './Spinner';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
export { default as Tabs } from './Tabs';
|
||||
export { default as Avatar } from './Avatar';
|
||||
10
src/data/categories.ts
Normal file
10
src/data/categories.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Category } from '../types';
|
||||
|
||||
export const categories: Category[] = [
|
||||
{ id: 'dairy', name: 'Dairy', emoji: '🥛' },
|
||||
{ id: 'grocery', name: 'Grocery', emoji: '🛒' },
|
||||
{ id: 'beverages', name: 'Beverages', emoji: '🥤' },
|
||||
{ id: 'snacks', name: 'Snacks', emoji: '🍪' },
|
||||
{ id: 'personal', name: 'Personal Care', emoji: '🧴' },
|
||||
{ id: 'household', name: 'Household', emoji: '🏠' },
|
||||
];
|
||||
12
src/data/customers.ts
Normal file
12
src/data/customers.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Customer } from '../types';
|
||||
|
||||
export const customers: Customer[] = [
|
||||
{ id: 'c1', name: 'Rahul Sharma', phone: '9876543210', email: 'rahul@example.com', dob: '1990-05-15', loyaltyPoints: 450, tier: 'gold', totalSpent: 12500, storeCredit: 0, lastVisit: '2026-06-25T14:30:00Z', initials: 'RS' },
|
||||
{ id: 'c2', name: 'Priya Patel', phone: '9876543211', email: 'priya@example.com', dob: '1985-08-20', loyaltyPoints: 1200, tier: 'platinum', totalSpent: 45000, storeCredit: 500, lastVisit: '2026-06-26T09:15:00Z', initials: 'PP' },
|
||||
{ id: 'c3', name: 'Amit Kumar', phone: '9876543212', email: 'amit@example.com', dob: '1992-11-10', loyaltyPoints: 150, tier: 'silver', totalSpent: 3500, storeCredit: 0, lastVisit: '2026-06-20T11:45:00Z', initials: 'AK' },
|
||||
{ id: 'c4', name: 'Sneha Gupta', phone: '9876543213', email: 'sneha@example.com', dob: '1988-02-25', loyaltyPoints: 800, tier: 'gold', totalSpent: 22000, storeCredit: 150, lastVisit: '2026-06-24T16:20:00Z', initials: 'SG' },
|
||||
{ id: 'c5', name: 'Vikram Singh', phone: '9876543214', email: 'vikram@example.com', dob: '1975-07-08', loyaltyPoints: 2100, tier: 'platinum', totalSpent: 85000, storeCredit: 0, lastVisit: '2026-06-26T10:05:00Z', initials: 'VS' },
|
||||
{ id: 'c6', name: 'Neha Reddy', phone: '9876543215', email: 'neha@example.com', dob: '1995-04-12', loyaltyPoints: 50, tier: 'silver', totalSpent: 1200, storeCredit: 0, lastVisit: '2026-06-15T18:30:00Z', initials: 'NR' },
|
||||
{ id: 'c7', name: 'Sanjay Verma', phone: '9876543216', email: 'sanjay@example.com', dob: '1982-09-30', loyaltyPoints: 320, tier: 'silver', totalSpent: 8900, storeCredit: 0, lastVisit: '2026-06-22T13:10:00Z', initials: 'SV' },
|
||||
{ id: 'c8', name: 'Pooja Iyer', phone: '9876543217', email: 'pooja@example.com', dob: '1991-12-05', loyaltyPoints: 650, tier: 'gold', totalSpent: 18500, storeCredit: 0, lastVisit: '2026-06-25T19:45:00Z', initials: 'PI' },
|
||||
];
|
||||
33
src/data/dashboard.ts
Normal file
33
src/data/dashboard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DashboardStats } from '../types';
|
||||
|
||||
export const dashboardData: DashboardStats = {
|
||||
todayStats: {
|
||||
sales: 24870,
|
||||
transactions: 148,
|
||||
avgBasket: 168,
|
||||
lowStockCount: 3,
|
||||
},
|
||||
weeklySales: [
|
||||
{ day: 'Mon', amount: 18500 },
|
||||
{ day: 'Tue', amount: 21200 },
|
||||
{ day: 'Wed', amount: 19800 },
|
||||
{ day: 'Thu', amount: 20500 },
|
||||
{ day: 'Fri', amount: 25600 },
|
||||
{ day: 'Sat', amount: 32000 },
|
||||
{ day: 'Sun', amount: 29400 },
|
||||
],
|
||||
topProducts: [
|
||||
{ rank: 1, name: 'Amul Milk 1L', qty: 45, revenue: 2790, trend: 12 },
|
||||
{ rank: 2, name: 'Maggi 2-min', qty: 38, revenue: 532, trend: 5 },
|
||||
{ rank: 3, name: 'Coca-Cola 600ml', qty: 30, revenue: 1200, trend: -2 },
|
||||
{ rank: 4, name: 'Parle-G 800g', qty: 25, revenue: 1000, trend: 8 },
|
||||
{ rank: 5, name: 'Lay\'s Chips 26g', qty: 22, revenue: 440, trend: -1 },
|
||||
],
|
||||
recentActivity: [
|
||||
{ id: 'a1', type: 'sale', text: 'Sale completed: ₹304.00 (UPI)', time: '2 mins ago', color: 'green' },
|
||||
{ id: 'a2', type: 'alert', text: 'Low stock alert: Basmati Rice 1kg (4 left)', time: '15 mins ago', color: 'amber' },
|
||||
{ id: 'a3', type: 'po', text: 'Purchase Order #PO-001 received', time: '1 hour ago', color: 'blue' },
|
||||
{ id: 'a4', type: 'sale', text: 'Sale completed: ₹1,250.00 (Card)', time: '2 hours ago', color: 'green' },
|
||||
{ id: 'a5', type: 'refund', text: 'Refund processed: ₹145.00 (Fortune Oil)', time: '3 hours ago', color: 'red' },
|
||||
],
|
||||
};
|
||||
34
src/data/products.ts
Normal file
34
src/data/products.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Product } from '../types';
|
||||
|
||||
export const products: Product[] = [
|
||||
// Dairy
|
||||
{ id: 'p1', name: 'Amul Milk 1L', sku: 'DAI-001', barcode: '10001', price: 62, costPrice: 50, taxRate: 18, categoryId: 'dairy', categoryName: 'Dairy', unit: 'L', emoji: '🥛', stock: 50, reorderPoint: 10, status: 'in_stock' },
|
||||
{ id: 'p2', name: 'Amul Butter 500g', sku: 'DAI-002', barcode: '10002', price: 245, costPrice: 200, taxRate: 18, categoryId: 'dairy', categoryName: 'Dairy', unit: 'g', emoji: '🧈', stock: 30, reorderPoint: 5, status: 'in_stock' },
|
||||
{ id: 'p3', name: 'Curd 400g', sku: 'DAI-003', barcode: '10003', price: 48, costPrice: 38, taxRate: 18, categoryId: 'dairy', categoryName: 'Dairy', unit: 'g', emoji: '🥣', stock: 40, reorderPoint: 10, status: 'in_stock' },
|
||||
{ id: 'p4', name: 'Paneer 200g', sku: 'DAI-004', barcode: '10004', price: 90, costPrice: 70, taxRate: 18, categoryId: 'dairy', categoryName: 'Dairy', unit: 'g', emoji: '🧀', stock: 20, reorderPoint: 5, status: 'in_stock' },
|
||||
// Grocery
|
||||
{ id: 'p5', name: 'Basmati Rice 1kg', sku: 'GRO-001', barcode: '20001', price: 180, costPrice: 150, taxRate: 18, categoryId: 'grocery', categoryName: 'Grocery', unit: 'kg', emoji: '🍚', stock: 4, reorderPoint: 10, status: 'low_stock' },
|
||||
{ id: 'p6', name: 'Fortune Oil 1L', sku: 'GRO-002', barcode: '20002', price: 145, costPrice: 120, taxRate: 18, categoryId: 'grocery', categoryName: 'Grocery', unit: 'L', emoji: '🛢️', stock: 60, reorderPoint: 15, status: 'in_stock' },
|
||||
{ id: 'p7', name: 'Toor Dal 500g', sku: 'GRO-003', barcode: '20003', price: 90, costPrice: 75, taxRate: 18, categoryId: 'grocery', categoryName: 'Grocery', unit: 'g', emoji: '🫘', stock: 45, reorderPoint: 10, status: 'in_stock' },
|
||||
{ id: 'p8', name: 'Maggi 2-min', sku: 'GRO-004', barcode: '20004', price: 14, costPrice: 10, taxRate: 18, categoryId: 'grocery', categoryName: 'Grocery', unit: 'pc', emoji: '🍜', stock: 120, reorderPoint: 20, status: 'in_stock' },
|
||||
// Beverages
|
||||
{ id: 'p9', name: 'Coca-Cola 600ml', sku: 'BEV-001', barcode: '30001', price: 40, costPrice: 30, taxRate: 18, categoryId: 'beverages', categoryName: 'Beverages', unit: 'ml', emoji: '🥤', stock: 80, reorderPoint: 20, status: 'in_stock' },
|
||||
{ id: 'p10', name: 'Frooti 250ml', sku: 'BEV-002', barcode: '30002', price: 15, costPrice: 10, taxRate: 18, categoryId: 'beverages', categoryName: 'Beverages', unit: 'ml', emoji: '🥭', stock: 100, reorderPoint: 25, status: 'in_stock' },
|
||||
{ id: 'p11', name: 'Bisleri 1L', sku: 'BEV-003', barcode: '30003', price: 20, costPrice: 12, taxRate: 18, categoryId: 'beverages', categoryName: 'Beverages', unit: 'L', emoji: '💧', stock: 150, reorderPoint: 30, status: 'in_stock' },
|
||||
{ id: 'p12', name: 'Red Bull 250ml', sku: 'BEV-004', barcode: '30004', price: 125, costPrice: 100, taxRate: 18, categoryId: 'beverages', categoryName: 'Beverages', unit: 'ml', emoji: '🔋', stock: 40, reorderPoint: 10, status: 'in_stock' },
|
||||
// Snacks
|
||||
{ id: 'p13', name: 'Parle-G 800g', sku: 'SNA-001', barcode: '40001', price: 40, costPrice: 30, taxRate: 18, categoryId: 'snacks', categoryName: 'Snacks', unit: 'g', emoji: '🍪', stock: 90, reorderPoint: 20, status: 'in_stock' },
|
||||
{ id: 'p14', name: 'Lay\'s Chips 26g', sku: 'SNA-002', barcode: '40002', price: 20, costPrice: 15, taxRate: 18, categoryId: 'snacks', categoryName: 'Snacks', unit: 'g', emoji: '🥔', stock: 110, reorderPoint: 20, status: 'in_stock' },
|
||||
{ id: 'p15', name: 'KitKat 50g', sku: 'SNA-003', barcode: '40003', price: 50, costPrice: 38, taxRate: 18, categoryId: 'snacks', categoryName: 'Snacks', unit: 'g', emoji: '🍫', stock: 75, reorderPoint: 15, status: 'in_stock' },
|
||||
{ id: 'p16', name: 'Hide&Seek', sku: 'SNA-004', barcode: '40004', price: 30, costPrice: 22, taxRate: 18, categoryId: 'snacks', categoryName: 'Snacks', unit: 'pc', emoji: '🍪', stock: 65, reorderPoint: 15, status: 'in_stock' },
|
||||
// Personal Care
|
||||
{ id: 'p17', name: 'Colgate 200g', sku: 'PER-001', barcode: '50001', price: 99, costPrice: 80, taxRate: 18, categoryId: 'personal', categoryName: 'Personal Care', unit: 'g', emoji: '🪥', stock: 0, reorderPoint: 10, status: 'out_of_stock' },
|
||||
{ id: 'p18', name: 'Dove Soap 75g', sku: 'PER-002', barcode: '50002', price: 60, costPrice: 45, taxRate: 18, categoryId: 'personal', categoryName: 'Personal Care', unit: 'g', emoji: '🧼', stock: 55, reorderPoint: 10, status: 'in_stock' },
|
||||
{ id: 'p19', name: 'Head & Shoulders 180ml', sku: 'PER-003', barcode: '50003', price: 199, costPrice: 160, taxRate: 18, categoryId: 'personal', categoryName: 'Personal Care', unit: 'ml', emoji: '🧴', stock: 35, reorderPoint: 8, status: 'in_stock' },
|
||||
{ id: 'p20', name: 'Dettol 200ml', sku: 'PER-004', barcode: '50004', price: 80, costPrice: 65, taxRate: 18, categoryId: 'personal', categoryName: 'Personal Care', unit: 'ml', emoji: '🧴', stock: 45, reorderPoint: 10, status: 'in_stock' },
|
||||
// Household
|
||||
{ id: 'p21', name: 'Surf Excel 1kg', sku: 'HOU-001', barcode: '60001', price: 185, costPrice: 150, taxRate: 18, categoryId: 'household', categoryName: 'Household', unit: 'kg', emoji: '👕', stock: 25, reorderPoint: 5, status: 'in_stock' },
|
||||
{ id: 'p22', name: 'Vim Bar 200g', sku: 'HOU-002', barcode: '60002', price: 35, costPrice: 25, taxRate: 18, categoryId: 'household', categoryName: 'Household', unit: 'g', emoji: '🧽', stock: 85, reorderPoint: 15, status: 'in_stock' },
|
||||
{ id: 'p23', name: 'Harpic 500ml', sku: 'HOU-003', barcode: '60003', price: 130, costPrice: 100, taxRate: 18, categoryId: 'household', categoryName: 'Household', unit: 'ml', emoji: '🚽', stock: 30, reorderPoint: 8, status: 'in_stock' },
|
||||
{ id: 'p24', name: 'Lizol 500ml', sku: 'HOU-004', barcode: '60004', price: 120, costPrice: 95, taxRate: 18, categoryId: 'household', categoryName: 'Household', unit: 'ml', emoji: '🧹', stock: 40, reorderPoint: 10, status: 'in_stock' },
|
||||
];
|
||||
9
src/data/promotions.ts
Normal file
9
src/data/promotions.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Promotion } from '../types';
|
||||
|
||||
export const promotions: Promotion[] = [
|
||||
{ id: 'promo1', name: 'Weekend Sale 10%', type: 'percentage', value: 10, description: 'Get 10% off on all items this weekend', applyTo: 'All', startDate: '2026-06-25', endDate: '2026-06-28', status: 'active', usageCount: 45, icon: '🏷️' },
|
||||
{ id: 'promo2', name: 'Buy 2 Get 1 Free on Snacks', type: 'buy_x_get_y', value: 1, description: 'Buy 2 snacks, get 1 free', applyTo: 'Snacks', startDate: '2026-06-20', endDate: '2026-06-30', status: 'active', usageCount: 112, icon: '🎁' },
|
||||
{ id: 'promo3', name: 'Birthday Special', type: 'fixed', value: 100, description: '₹100 off on your birthday month', applyTo: 'All', startDate: '2026-01-01', endDate: '2026-12-31', status: 'active', usageCount: 8, icon: '🎂' },
|
||||
{ id: 'promo4', name: 'Diwali Dhamaka', type: 'percentage', value: 25, description: '25% off on all items for Diwali', applyTo: 'All', startDate: '2026-10-20', endDate: '2026-11-05', status: 'scheduled', usageCount: 0, icon: '🪔' },
|
||||
{ id: 'promo5', name: 'Summer Drinks 15%', type: 'percentage', value: 15, description: '15% off on all beverages', applyTo: 'Beverages', startDate: '2026-04-01', endDate: '2026-05-31', status: 'expired', usageCount: 345, icon: '🥤' },
|
||||
];
|
||||
24
src/data/sales.ts
Normal file
24
src/data/sales.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Sale } from '../types';
|
||||
|
||||
export const sales: Sale[] = Array.from({ length: 30 }).map((_, i) => {
|
||||
const isToday = i < 15;
|
||||
const dateStr = isToday
|
||||
? `2026-06-26T${10 + Math.floor(i / 3)}:${(i * 15) % 60}:00Z`
|
||||
: `2026-06-25T${10 + Math.floor(i / 3)}:${(i * 15) % 60}:00Z`;
|
||||
|
||||
return {
|
||||
id: `INV-2026-${1000 + i}`,
|
||||
date: dateStr,
|
||||
cashier: i % 3 === 0 ? 'Prabhakaran' : 'Abhishek',
|
||||
customerId: i % 4 === 0 ? `c${(i % 8) + 1}` : undefined,
|
||||
items: [
|
||||
{ productId: 'p1', name: 'Amul Milk 1L', qty: 2, unitPrice: 62 },
|
||||
{ productId: 'p5', name: 'Basmati Rice 1kg', qty: 1, unitPrice: 180 },
|
||||
],
|
||||
subtotal: 304,
|
||||
taxAmount: 0,
|
||||
discountAmount: 0,
|
||||
total: 304,
|
||||
paymentMethod: i % 3 === 0 ? 'cash' : i % 2 === 0 ? 'upi' : 'card',
|
||||
};
|
||||
});
|
||||
16
src/data/staff.ts
Normal file
16
src/data/staff.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { User } from '@/types';
|
||||
|
||||
export const staff: User[] = [
|
||||
{
|
||||
id: 'EMP-001',
|
||||
name: 'Abhishek',
|
||||
role: 'cashier',
|
||||
pin: '1111'
|
||||
},
|
||||
{
|
||||
id: 'EMP-002',
|
||||
name: 'Suriya',
|
||||
role: 'manager',
|
||||
pin: '9999'
|
||||
}
|
||||
];
|
||||
45
src/data/suppliers.ts
Normal file
45
src/data/suppliers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Supplier, PurchaseOrder } from '../types';
|
||||
|
||||
export const suppliers: Supplier[] = [
|
||||
{ id: 's1', name: 'Amul Distributors', category: 'Dairy', phone: '080-1234567', email: 'orders@amul.local', paymentTerms: 'Net 15' },
|
||||
{ id: 's2', name: 'ITC Wholesale', category: 'Grocery', phone: '080-2345678', email: 'sales@itc.local', paymentTerms: 'Net 30' },
|
||||
{ id: 's3', name: 'Coca-Cola India', category: 'Beverages', phone: '080-3456789', email: 'dist@cocacola.local', paymentTerms: 'Net 7' },
|
||||
{ id: 's4', name: 'Parle Products', category: 'Snacks', phone: '080-4567890', email: 'supply@parle.local', paymentTerms: 'Net 30' },
|
||||
{ id: 's5', name: 'HUL Supply', category: 'Personal Care', phone: '080-5678901', email: 'orders@hul.local', paymentTerms: 'Net 30' },
|
||||
{ id: 's6', name: 'Reckitt Benckiser', category: 'Household', phone: '080-6789012', email: 'dist@rb.local', paymentTerms: 'Net 15' },
|
||||
];
|
||||
|
||||
export const purchaseOrders: PurchaseOrder[] = [
|
||||
{
|
||||
id: 'PO-001', supplierId: 's1', supplierName: 'Amul Distributors', value: 12500, status: 'received', orderedAt: '2026-06-20T10:00:00Z',
|
||||
items: [{ productId: 'p1', name: 'Amul Milk 1L', qtyOrdered: 100, qtyReceived: 100, unitCost: 50, total: 5000 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-002', supplierId: 's2', supplierName: 'ITC Wholesale', value: 8400, status: 'in_transit', orderedAt: '2026-06-25T11:30:00Z',
|
||||
items: [{ productId: 'p5', name: 'Basmati Rice 1kg', qtyOrdered: 50, qtyReceived: 0, unitCost: 150, total: 7500 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-003', supplierId: 's5', supplierName: 'HUL Supply', value: 4500, status: 'pending', orderedAt: '2026-06-26T09:15:00Z',
|
||||
items: [{ productId: 'p18', name: 'Dove Soap 75g', qtyOrdered: 100, qtyReceived: 0, unitCost: 45, total: 4500 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-004', supplierId: 's1', supplierName: 'Amul Distributors', value: 3500, status: 'pending', orderedAt: '2026-06-26T14:00:00Z',
|
||||
items: [{ productId: 'p4', name: 'Paneer 200g', qtyOrdered: 50, qtyReceived: 0, unitCost: 70, total: 3500 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-005', supplierId: 's3', supplierName: 'Coca-Cola India', value: 6000, status: 'received', orderedAt: '2026-06-22T10:00:00Z',
|
||||
items: [{ productId: 'p9', name: 'Coca-Cola 600ml', qtyOrdered: 200, qtyReceived: 200, unitCost: 30, total: 6000 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-006', supplierId: 's4', supplierName: 'Parle Products', value: 2400, status: 'in_transit', orderedAt: '2026-06-24T16:45:00Z',
|
||||
items: [{ productId: 'p13', name: 'Parle-G 800g', qtyOrdered: 80, qtyReceived: 0, unitCost: 30, total: 2400 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-007', supplierId: 's6', supplierName: 'Reckitt Benckiser', value: 5000, status: 'received', orderedAt: '2026-06-18T09:00:00Z',
|
||||
items: [{ productId: 'p23', name: 'Harpic 500ml', qtyOrdered: 50, qtyReceived: 50, unitCost: 100, total: 5000 }]
|
||||
},
|
||||
{
|
||||
id: 'PO-008', supplierId: 's5', supplierName: 'HUL Supply', value: 16000, status: 'in_transit', orderedAt: '2026-06-23T11:20:00Z',
|
||||
items: [{ productId: 'p19', name: 'Head & Shoulders 180ml', qtyOrdered: 100, qtyReceived: 0, unitCost: 160, total: 16000 }]
|
||||
},
|
||||
];
|
||||
61
src/hooks/useBarcodeScanner.ts
Normal file
61
src/hooks/useBarcodeScanner.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseBarcodeScannerOptions {
|
||||
onScan: (barcode: string) => void;
|
||||
ignoreIfFocused?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export function useBarcodeScanner({
|
||||
onScan,
|
||||
ignoreIfFocused = true,
|
||||
timeout = 50
|
||||
}: UseBarcodeScannerOptions) {
|
||||
const bufferRef = useRef<string>('');
|
||||
const lastKeyTimeRef = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if user is manually typing in an input/textarea
|
||||
if (ignoreIfFocused) {
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
const activeType = (document.activeElement as HTMLInputElement)?.type;
|
||||
|
||||
if (activeTag === 'INPUT' || activeTag === 'TEXTAREA') {
|
||||
// Allow exceptions for certain input types if needed in future
|
||||
if (activeType !== 'checkbox' && activeType !== 'radio') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const timeDiff = currentTime - lastKeyTimeRef.current;
|
||||
|
||||
// If more than 'timeout' ms passed between keys, it's probably human typing.
|
||||
// Reset the buffer.
|
||||
if (timeDiff > timeout) {
|
||||
bufferRef.current = '';
|
||||
}
|
||||
|
||||
// Scanner usually concludes with an Enter key
|
||||
if (e.key === 'Enter') {
|
||||
if (bufferRef.current.length > 3) {
|
||||
// Prevent form submissions if this was a global scan
|
||||
e.preventDefault();
|
||||
onScan(bufferRef.current);
|
||||
bufferRef.current = '';
|
||||
}
|
||||
}
|
||||
// Only capture single printable characters
|
||||
else if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
bufferRef.current += e.key;
|
||||
}
|
||||
|
||||
lastKeyTimeRef.current = currentTime;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onScan, ignoreIfFocused, timeout]);
|
||||
}
|
||||
17
src/index.css
Normal file
17
src/index.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #f3f4f6; /* gray-100 */
|
||||
}
|
||||
|
||||
/* Touch optimizations */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
40
src/lib/utils.ts
Normal file
40
src/lib/utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getStatusBadge(status: string): { label: string; className: string } {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'in_stock':
|
||||
case 'received':
|
||||
case 'active':
|
||||
case 'ok':
|
||||
return { label: status.replace('_', ' ').toUpperCase(), className: 'bg-green-100 text-green-700' };
|
||||
case 'low_stock':
|
||||
case 'pending':
|
||||
case 'scheduled':
|
||||
case 'low':
|
||||
return { label: status.replace('_', ' ').toUpperCase(), className: 'bg-amber-100 text-amber-700' };
|
||||
case 'out_of_stock':
|
||||
case 'oos':
|
||||
case 'cancelled':
|
||||
return { label: status.replace('_', ' ').toUpperCase(), className: 'bg-red-100 text-red-700' };
|
||||
case 'in_transit':
|
||||
case 'loyalty':
|
||||
return { label: status.replace('_', ' ').toUpperCase(), className: 'bg-blue-100 text-blue-700' };
|
||||
case 'expired':
|
||||
default:
|
||||
return { label: status.replace('_', ' ').toUpperCase(), className: 'bg-gray-100 text-gray-700' };
|
||||
}
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
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>,
|
||||
)
|
||||
122
src/pages/auth/LoginPage.tsx
Normal file
122
src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { Delete, Lock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [pin, setPin] = useState('');
|
||||
const { login, currentUser } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
navigate('/pos', { replace: true });
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
const handleKeyPress = (key: string) => {
|
||||
if (pin.length < 4) {
|
||||
setPin(prev => prev + key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackspace = () => {
|
||||
setPin(prev => prev.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
if (pin.length !== 4) return;
|
||||
|
||||
const success = login(pin);
|
||||
if (success) {
|
||||
toast.success('Login successful');
|
||||
} else {
|
||||
toast.error('Invalid PIN');
|
||||
setPin('');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-submit when 4 digits are reached
|
||||
useEffect(() => {
|
||||
if (pin.length === 4) {
|
||||
// Small timeout to let the user see the 4th dot fill up
|
||||
const timer = setTimeout(() => {
|
||||
handleLogin();
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pin]);
|
||||
|
||||
const keypad = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', '⌫'];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-100 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md p-8 flex flex-col items-center animate-in zoom-in-95 duration-300">
|
||||
|
||||
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mb-6 text-primary">
|
||||
<Lock className="w-10 h-10" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-extrabold text-gray-900 mb-2">Terminal Locked</h1>
|
||||
<p className="text-gray-500 font-medium mb-8 text-center">
|
||||
Enter your 4-digit staff PIN to unlock the register.
|
||||
</p>
|
||||
|
||||
{/* PIN Dots */}
|
||||
<div className="flex gap-4 mb-10">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-4 h-4 rounded-full transition-all duration-200 ${
|
||||
i < pin.length ? 'bg-primary scale-110 shadow-md' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Keypad */}
|
||||
<div className="grid grid-cols-3 gap-3 w-full max-w-[280px]">
|
||||
{keypad.map((key) => {
|
||||
if (key === 'C') {
|
||||
return (
|
||||
<button
|
||||
key="clear"
|
||||
onClick={() => setPin('')}
|
||||
className="h-16 rounded-2xl font-bold text-xl bg-gray-50 text-red-500 active:bg-gray-200 select-none touch-manipulation transition-colors"
|
||||
>
|
||||
C
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (key === '⌫') {
|
||||
return (
|
||||
<button
|
||||
key="backspace"
|
||||
onClick={handleBackspace}
|
||||
className="h-16 rounded-2xl font-bold text-xl bg-gray-50 text-gray-600 active:bg-gray-200 select-none touch-manipulation transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Delete className="w-6 h-6" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleKeyPress(key)}
|
||||
className="h-16 rounded-2xl font-extrabold text-2xl bg-gray-50 text-gray-900 active:bg-primary active:text-white shadow-sm active:scale-95 select-none touch-manipulation transition-all"
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-xs text-gray-400 font-medium">
|
||||
<p>Cashier PIN: 1111 | Manager PIN: 9999</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/pages/customers/AddCustomerModal.tsx
Normal file
120
src/pages/customers/AddCustomerModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Input } from '@/components/ui';
|
||||
import { Customer } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AddCustomerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (customer: Customer) => void;
|
||||
}
|
||||
|
||||
export default function AddCustomerModal({ isOpen, onClose, onSave }: AddCustomerModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
dob: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
dob: '',
|
||||
notes: '',
|
||||
});
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) newErrors.name = 'Name is required';
|
||||
if (!formData.phone.trim()) newErrors.phone = 'Phone is required';
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
const initials = formData.name.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2);
|
||||
|
||||
const newCustomer: Customer = {
|
||||
id: `c${Date.now()}`,
|
||||
name: formData.name,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
dob: formData.dob,
|
||||
loyaltyPoints: 0,
|
||||
tier: 'silver',
|
||||
totalSpent: 0,
|
||||
storeCredit: 0,
|
||||
lastVisit: new Date().toISOString(),
|
||||
initials,
|
||||
};
|
||||
|
||||
onSave(newCustomer);
|
||||
toast.success('Customer saved successfully!');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Add New Customer">
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<Input
|
||||
label="Full Name *"
|
||||
placeholder="e.g. Rahul Sharma"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Phone Number *"
|
||||
placeholder="e.g. 9876543210"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
error={errors.phone}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="e.g. rahul@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
value={formData.dob}
|
||||
onChange={(e) => handleChange('dob', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Notes"
|
||||
placeholder="Any special preferences or notes..."
|
||||
value={formData.notes}
|
||||
onChange={(e) => handleChange('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
<Button className="flex-1" onClick={handleSave}>Save Customer</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
273
src/pages/customers/CustomersPage.tsx
Normal file
273
src/pages/customers/CustomersPage.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { SearchBar, Button, Card, Table, Badge } from '@/components/ui';
|
||||
import { customers as initialCustomers } from '@/data/customers';
|
||||
import { sales } from '@/data/sales';
|
||||
import { Customer } from '@/types';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import AddCustomerModal from './AddCustomerModal';
|
||||
import { Users, Phone, Mail, Calendar, Gift, Star, Award, CreditCard, UserPlus } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [customersList, setCustomersList] = useState<Customer[]>(initialCustomers);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTier, setActiveTier] = useState<string>('all');
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
const selectedCustomer = useMemo(() =>
|
||||
customersList.find(c => c.id === selectedCustomerId) || null
|
||||
, [customersList, selectedCustomerId]);
|
||||
|
||||
const customerSales = useMemo(() =>
|
||||
selectedCustomerId ? sales.filter(s => s.customerId === selectedCustomerId).slice(0, 5) : []
|
||||
, [selectedCustomerId]);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
let result = customersList;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.phone.includes(q) ||
|
||||
c.email?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (activeTier !== 'all') {
|
||||
result = result.filter(c => c.tier === activeTier);
|
||||
}
|
||||
return result;
|
||||
}, [customersList, searchQuery, activeTier]);
|
||||
|
||||
const handleAddCustomer = (newCustomer: Customer) => {
|
||||
setCustomersList([newCustomer, ...customersList]);
|
||||
setSelectedCustomerId(newCustomer.id);
|
||||
};
|
||||
|
||||
const getTierColor = (tier: string) => {
|
||||
if (tier === 'platinum') return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
if (tier === 'gold') return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200'; // silver
|
||||
};
|
||||
|
||||
const getTierIcon = (tier: string) => {
|
||||
if (tier === 'platinum') return '🥇';
|
||||
if (tier === 'gold') return '🥈';
|
||||
return '🥉';
|
||||
};
|
||||
|
||||
const calculateProgress = (points: number, tier: string) => {
|
||||
let nextThreshold = 500; // silver -> gold
|
||||
let currentBase = 0;
|
||||
|
||||
if (tier === 'gold') {
|
||||
nextThreshold = 2000;
|
||||
currentBase = 500;
|
||||
} else if (tier === 'platinum') {
|
||||
return 100; // maxed out
|
||||
}
|
||||
|
||||
const progress = ((points - currentBase) / (nextThreshold - currentBase)) * 100;
|
||||
return Math.min(Math.max(progress, 0), 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-gray-50 overflow-hidden">
|
||||
|
||||
{/* LEFT PANEL - Customer List */}
|
||||
<div className="w-full lg:w-[450px] flex flex-col bg-white border-r border-gray-200 shrink-0">
|
||||
<div className="p-4 border-b border-gray-100 shrink-0 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customers</h2>
|
||||
<Button size="sm" icon={<UserPlus className="w-4 h-4" />} onClick={() => setIsAddModalOpen(true)}>
|
||||
Add Customer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SearchBar
|
||||
placeholder="Search name, phone, email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{['all', 'silver', 'gold', 'platinum'].map(tier => (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => setActiveTier(tier)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-semibold capitalize whitespace-nowrap transition-colors ${
|
||||
activeTier === tier
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-600 '
|
||||
}`}
|
||||
>
|
||||
{tier === 'all' ? 'All' : `${getTierIcon(tier)} ${tier}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
|
||||
{filteredCustomers.map(customer => {
|
||||
const isSelected = selectedCustomerId === customer.id;
|
||||
return (
|
||||
<div
|
||||
key={customer.id}
|
||||
onClick={() => setSelectedCustomerId(customer.id)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-gray-100 '
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold shrink-0 ${
|
||||
isSelected ? 'bg-primary text-white' : 'bg-gray-200 text-gray-700'
|
||||
}`}>
|
||||
{customer.initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<h3 className="font-bold text-gray-900 truncate">{customer.name}</h3>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full border uppercase tracking-wider ${getTierColor(customer.tier)}`}>
|
||||
{customer.tier}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-2 truncate">{customer.phone} {customer.email && `· ${customer.email}`}</p>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="text-xs font-semibold text-gray-700">
|
||||
Total spent: <span className="text-primary">{formatCurrency(customer.totalSpent)}</span>
|
||||
</div>
|
||||
<div className="text-xs font-bold text-amber-600 flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-current" /> {customer.loyaltyPoints} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredCustomers.length === 0 && (
|
||||
<div className="m-auto text-center py-10">
|
||||
<p className="text-gray-500 font-medium">No customers found.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL - Profile Detail */}
|
||||
<div className="hidden lg:flex flex-1 flex-col overflow-hidden bg-gray-50 relative">
|
||||
{selectedCustomer ? (
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-8">
|
||||
|
||||
{/* Header Profile */}
|
||||
<div className="flex items-center justify-between bg-white p-6 rounded-2xl border border-gray-200 shadow-sm">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-24 h-24 rounded-full bg-primary text-white flex items-center justify-center text-3xl font-bold shadow-inner">
|
||||
{selectedCustomer.initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">{selectedCustomer.name}</h1>
|
||||
<span className={`text-xs font-bold px-3 py-1 rounded-full border uppercase tracking-wider ${getTierColor(selectedCustomer.tier)}`}>
|
||||
{getTierIcon(selectedCustomer.tier)} {selectedCustomer.tier} Member
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 font-medium mt-2">
|
||||
<span className="flex items-center gap-1.5"><Phone className="w-4 h-4" /> {selectedCustomer.phone}</span>
|
||||
{selectedCustomer.email && <span className="flex items-center gap-1.5"><Mail className="w-4 h-4" /> {selectedCustomer.email}</span>}
|
||||
{selectedCustomer.dob && <span className="flex items-center gap-1.5"><Calendar className="w-4 h-4" /> {selectedCustomer.dob}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => toast('Edit feature coming soon')}>Edit Profile</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats & Loyalty */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card className="flex flex-col p-5">
|
||||
<div className="flex items-center gap-3 mb-2 text-gray-500 font-semibold text-sm">
|
||||
<CreditCard className="w-5 h-5 text-green-500" /> Total Spent
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
||||
</Card>
|
||||
<Card className="flex flex-col p-5">
|
||||
<div className="flex items-center gap-3 mb-2 text-gray-500 font-semibold text-sm">
|
||||
<Award className="w-5 h-5 text-amber-500" /> Loyalty Points
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{selectedCustomer.loyaltyPoints} <span className="text-base font-medium text-gray-400">pts</span></div>
|
||||
</Card>
|
||||
<Card className="flex flex-col p-5">
|
||||
<div className="flex items-center gap-3 mb-2 text-gray-500 font-semibold text-sm">
|
||||
<Gift className="w-5 h-5 text-purple-500" /> Store Credit
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{formatCurrency(selectedCustomer.storeCredit)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Loyalty Progress */}
|
||||
<Card title="Loyalty Tier Progress" action={
|
||||
selectedCustomer.loyaltyPoints > 0 && (
|
||||
<Button size="sm" onClick={() => toast.success('Points redemption flow coming soon!')}>Redeem Points</Button>
|
||||
)
|
||||
}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between text-sm font-semibold text-gray-600 mb-1">
|
||||
<span>{selectedCustomer.tier.toUpperCase()} TIER</span>
|
||||
<span>{selectedCustomer.tier === 'platinum' ? 'MAX TIER' : `Next Tier`}</span>
|
||||
</div>
|
||||
<div className="w-full h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 transition-all duration-1000 ease-out rounded-full"
|
||||
style={{ width: `${calculateProgress(selectedCustomer.loyaltyPoints, selectedCustomer.tier)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{selectedCustomer.tier === 'platinum'
|
||||
? 'Customer has reached the highest loyalty tier.'
|
||||
: `Earn ${selectedCustomer.tier === 'silver' ? 500 - selectedCustomer.loyaltyPoints : 2000 - selectedCustomer.loyaltyPoints} more points to reach ${selectedCustomer.tier === 'silver' ? 'Gold' : 'Platinum'}!`}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Purchase History */}
|
||||
<Card title="Recent Purchase History">
|
||||
{customerSales.length > 0 ? (
|
||||
<Table
|
||||
data={customerSales}
|
||||
columns={[
|
||||
{ key: 'date', label: 'Date', render: s => new Date(s.date).toLocaleDateString() },
|
||||
{ key: 'items', label: 'Items', render: s => s.items.map(i => i.name).join(', ') },
|
||||
{ key: 'amount', label: 'Amount', render: s => <span className="font-bold">{formatCurrency(s.total)}</span> },
|
||||
{ key: 'payment', label: 'Payment', render: s => <Badge variant="blue" className="capitalize">{s.paymentMethod}</Badge> },
|
||||
{ key: 'action', label: '', render: () => <Button variant="outline" size="sm">View Receipt</Button> },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">No purchase history found for this customer.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto flex flex-col items-center justify-center text-center max-w-sm">
|
||||
<div className="w-24 h-24 bg-primary/10 text-primary rounded-full flex items-center justify-center mb-6">
|
||||
<Users className="w-12 h-12" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Customer Profiles</h2>
|
||||
<p className="text-gray-500">Select a customer from the list on the left to view their detailed profile, loyalty progress, and purchase history.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddCustomerModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
onSave={handleAddCustomer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/dashboard/DashboardPage.tsx
Normal file
165
src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { StatCard, Card, Table, Badge } from '@/components/ui';
|
||||
import { dashboardData } from '@/data/dashboard';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { DollarSign, Receipt, ShoppingBag, AlertCircle, Download, ShoppingCart, Archive, FileText, Truck } from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { todayStats, weeklySales, topProducts, recentActivity } = dashboardData;
|
||||
|
||||
const maxSale = Math.max(...weeklySales.map(d => d.amount));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 bg-gray-100 flex flex-col gap-6">
|
||||
|
||||
{/* KPI ROW */}
|
||||
<div className="grid grid-cols-4 gap-6 shrink-0">
|
||||
<StatCard
|
||||
label="Today's Sales"
|
||||
value={formatCurrency(todayStats.sales)}
|
||||
change="12.4%"
|
||||
changeType="up"
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
iconBg="bg-green-100 text-green-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Transactions"
|
||||
value={todayStats.transactions}
|
||||
change="8 more"
|
||||
changeType="up"
|
||||
icon={<Receipt className="w-6 h-6" />}
|
||||
iconBg="bg-blue-100 text-blue-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Basket Size"
|
||||
value={formatCurrency(todayStats.avgBasket)}
|
||||
change="3.2%"
|
||||
changeType="down"
|
||||
icon={<ShoppingBag className="w-6 h-6" />}
|
||||
iconBg="bg-amber-100 text-amber-700"
|
||||
/>
|
||||
<div onClick={() => navigate('/inventory')} className="cursor-pointer active:scale-[0.98] transition-transform select-none touch-manipulation">
|
||||
<StatCard
|
||||
label="Low Stock Items"
|
||||
value={todayStats.lowStockCount}
|
||||
change="Needs reorder"
|
||||
changeType="down"
|
||||
icon={<AlertCircle className="w-6 h-6" />}
|
||||
iconBg="bg-red-100 text-red-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2-COLUMN GRID */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
|
||||
{/* LEFT COLUMN (2fr) */}
|
||||
<div className="col-span-2 flex flex-col gap-6">
|
||||
<Card
|
||||
title="Sales This Week"
|
||||
action={
|
||||
<button className="flex items-center text-sm font-semibold text-primary bg-primary/10 px-3 min-h-[40px] rounded-lg active:bg-primary/20 select-none touch-manipulation transition-colors">
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Export
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="h-[250px] flex items-end justify-between pt-6">
|
||||
{weeklySales.map((day, idx) => {
|
||||
const isToday = idx === 3; // Mocking Thursday as today
|
||||
const heightPct = (day.amount / maxSale) * 100;
|
||||
return (
|
||||
<div key={day.day} className="flex flex-col items-center w-full group">
|
||||
<div className="text-xs font-semibold text-gray-500 mb-2 opacity-0 group-active:opacity-100 transition-opacity select-none touch-manipulation">
|
||||
{formatCurrency(day.amount)}
|
||||
</div>
|
||||
<div
|
||||
className={`w-12 rounded-t-md ${isToday ? 'bg-primary shadow-md' : 'bg-primary/20'} transition-all duration-500`}
|
||||
style={{ height: `${heightPct}%`, minHeight: '4px' }}
|
||||
/>
|
||||
<div className={`mt-3 text-sm select-none ${isToday ? 'font-bold text-gray-900' : 'font-medium text-gray-500'}`}>
|
||||
{day.day}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Top Products Today">
|
||||
<Table
|
||||
data={topProducts.map(p => ({ ...p, id: p.rank }))}
|
||||
columns={[
|
||||
{ key: 'rank', label: 'Rank', render: (item) => <span className="font-bold text-gray-500">#{item.rank}</span> },
|
||||
{ key: 'name', label: 'Product', render: (item) => <span className="font-semibold text-gray-900">{item.name}</span> },
|
||||
{ key: 'qty', label: 'Qty Sold' },
|
||||
{ key: 'revenue', label: 'Revenue', render: (item) => <span className="font-bold text-gray-900">{formatCurrency(item.revenue)}</span> },
|
||||
{
|
||||
key: 'trend',
|
||||
label: 'Trend',
|
||||
render: (item) => (
|
||||
<Badge variant={item.trend > 0 ? 'green' : 'red'}>
|
||||
{item.trend > 0 ? '▲' : '▼'} {Math.abs(item.trend)}%
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN (1fr) */}
|
||||
<div className="col-span-1 flex flex-col gap-6">
|
||||
<Card title="Quick Actions">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button onClick={() => navigate('/pos')} className="min-h-[100px] bg-primary/5 border border-primary/10 rounded-xl flex flex-col items-center justify-center gap-2 text-primary active:bg-primary/10 active:scale-[0.97] transition-all select-none touch-manipulation">
|
||||
<ShoppingCart className="w-8 h-8" />
|
||||
<span className="font-semibold">New Sale</span>
|
||||
</button>
|
||||
<button onClick={() => navigate('/inventory')} className="min-h-[100px] bg-blue-50 border border-blue-100 rounded-xl flex flex-col items-center justify-center gap-2 text-blue-700 active:bg-blue-100 active:scale-[0.97] transition-all select-none touch-manipulation">
|
||||
<Archive className="w-8 h-8" />
|
||||
<span className="font-semibold">Stocktake</span>
|
||||
</button>
|
||||
<button onClick={() => navigate('/reports')} className="min-h-[100px] bg-amber-50 border border-amber-100 rounded-xl flex flex-col items-center justify-center gap-2 text-amber-700 active:bg-amber-100 active:scale-[0.97] transition-all select-none touch-manipulation">
|
||||
<FileText className="w-8 h-8" />
|
||||
<span className="font-semibold">Z-Report</span>
|
||||
</button>
|
||||
<button onClick={() => navigate('/suppliers')} className="min-h-[100px] bg-green-50 border border-green-100 rounded-xl flex flex-col items-center justify-center gap-2 text-green-700 active:bg-green-100 active:scale-[0.97] transition-all select-none touch-manipulation">
|
||||
<Truck className="w-8 h-8" />
|
||||
<span className="font-semibold">Order Stock</span>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Activity Feed" className="flex-1">
|
||||
<div className="flex flex-col gap-5 mt-2">
|
||||
{recentActivity.map((activity, index) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
green: 'bg-green-500 ring-green-100',
|
||||
amber: 'bg-amber-500 ring-amber-100',
|
||||
blue: 'bg-blue-500 ring-blue-100',
|
||||
red: 'bg-red-500 ring-red-100',
|
||||
};
|
||||
return (
|
||||
<div key={activity.id} className="flex gap-4 relative">
|
||||
{/* Timeline line */}
|
||||
{index !== recentActivity.length - 1 && (
|
||||
<div className="absolute left-[5px] top-3 bottom-[-20px] w-[2px] bg-gray-100" />
|
||||
)}
|
||||
<div className={`w-3 h-3 rounded-full mt-1 shrink-0 ring-4 relative z-10 ${colorMap[activity.color]}`} />
|
||||
<div className="flex-1 pb-1">
|
||||
<p className="text-sm font-medium text-gray-900 leading-snug">{activity.text}</p>
|
||||
<span className="text-xs text-gray-500 font-medium inline-block">{activity.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/pages/inventory/AdjustStockModal.tsx
Normal file
88
src/pages/inventory/AdjustStockModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Input, Select } from '@/components/ui';
|
||||
import { Product } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AdjustStockModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
onConfirm: (productId: string, qty: number, type: string) => void;
|
||||
}
|
||||
|
||||
export default function AdjustStockModal({ isOpen, onClose, product, onConfirm }: AdjustStockModalProps) {
|
||||
const [type, setType] = useState('add');
|
||||
const [qty, setQty] = useState<number | ''>('');
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setType('add');
|
||||
setQty('');
|
||||
setReason('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!qty || Number(qty) <= 0) {
|
||||
toast.error('Please enter a valid quantity');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new total just to check validity
|
||||
const numQty = Number(qty);
|
||||
if ((type === 'remove' || type === 'damage' || type === 'write_off') && numQty > product.stock) {
|
||||
toast.error(`Cannot remove ${numQty}. Only ${product.stock} in stock.`);
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm(product.id, numQty, type);
|
||||
toast.success(`Stock adjusted successfully`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Adjust Stock: ${product.name}`}>
|
||||
<div className="flex flex-col gap-5 py-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100 flex items-center justify-between">
|
||||
<span className="text-gray-500 font-medium">Current Stock On Hand</span>
|
||||
<span className="text-2xl font-bold text-gray-900">{product.stock} <span className="text-sm font-medium text-gray-500">{product.unit}</span></span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Adjustment Type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
options={[
|
||||
{ value: 'add', label: 'Add Stock (+)' },
|
||||
{ value: 'remove', label: 'Remove Stock (-)' },
|
||||
{ value: 'damage', label: 'Damaged Goods (-)' },
|
||||
{ value: 'write_off', label: 'Write-off (-)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Quantity"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value ? Number(e.target.value) : '')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Reason (Optional)"
|
||||
placeholder="e.g. Found in back room"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
<Button className="flex-1" onClick={handleConfirm}>Confirm Adjustment</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
304
src/pages/inventory/InventoryPage.tsx
Normal file
304
src/pages/inventory/InventoryPage.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { StatCard, Card, Tabs, Table, Badge, Button, SearchBar, Select, Input } from '@/components/ui';
|
||||
import { products as initialProducts } from '@/data/products';
|
||||
import { categories } from '@/data/categories';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Product } from '@/types';
|
||||
import AdjustStockModal from './AdjustStockModal';
|
||||
import { Archive, AlertTriangle, XCircle, DollarSign, PackageCheck, ClipboardList } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Mock movement history
|
||||
const mockHistory = [
|
||||
{ id: '1', date: '2026-06-26 10:30', product: 'Amul Milk 1L', type: 'Add Stock', qtyChange: 50, reason: 'PO-2034', user: 'Admin', balance: 100 },
|
||||
{ id: '2', date: '2026-06-26 09:15', product: 'Lay\'s Chips 26g', type: 'Damage', qtyChange: -2, reason: 'Crushed packet', user: 'Cashier 1', balance: 108 },
|
||||
{ id: '3', date: '2026-06-25 18:45', product: 'Colgate 200g', type: 'Remove Stock', qtyChange: -1, reason: 'Expired', user: 'Admin', balance: 0 },
|
||||
{ id: '4', date: '2026-06-25 14:20', product: 'Red Bull 250ml', type: 'Add Stock', qtyChange: 24, reason: 'PO-2033', user: 'Admin', balance: 40 },
|
||||
{ id: '5', date: '2026-06-25 11:10', product: 'Frooti 250ml', type: 'Sale', qtyChange: -2, reason: 'POS Sale', user: 'Cashier 2', balance: 100 },
|
||||
{ id: '6', date: '2026-06-24 16:30', product: 'Toor Dal 500g', type: 'Sale', qtyChange: -5, reason: 'POS Sale', user: 'Cashier 1', balance: 45 },
|
||||
{ id: '7', date: '2026-06-24 09:00', product: 'Amul Butter 500g', type: 'Add Stock', qtyChange: 10, reason: 'PO-2032', user: 'Admin', balance: 30 },
|
||||
{ id: '8', date: '2026-06-23 15:45', product: 'Vim Bar 200g', type: 'Sale', qtyChange: -1, reason: 'POS Sale', user: 'Cashier 2', balance: 85 },
|
||||
{ id: '9', date: '2026-06-23 10:20', product: 'Dove Soap 75g', type: 'Sale', qtyChange: -3, reason: 'POS Sale', user: 'Cashier 1', balance: 55 },
|
||||
{ id: '10', date: '2026-06-22 12:00', product: 'Maggi 2-min', type: 'Write-off', qtyChange: -10, reason: 'Pest damage', user: 'Admin', balance: 120 },
|
||||
];
|
||||
|
||||
export default function InventoryPage() {
|
||||
const [activeTab, setActiveTab] = useState('levels');
|
||||
const [productsList, setProductsList] = useState<Product[]>(initialProducts);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
const [isAdjustModalOpen, setIsAdjustModalOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
|
||||
// Tab 3 state
|
||||
const [adjProduct, setAdjProduct] = useState('');
|
||||
const [adjType, setAdjType] = useState('add');
|
||||
const [adjQty, setAdjQty] = useState<number | ''>('');
|
||||
const [adjReason, setAdjReason] = useState('');
|
||||
|
||||
// Stats calculation
|
||||
const totalSKUs = productsList.length;
|
||||
const stockValue = productsList.reduce((acc, p) => acc + (p.stock * p.costPrice), 0);
|
||||
const lowStockCount = productsList.filter(p => p.stock <= p.reorderPoint && p.stock > 0).length;
|
||||
const outOfStockCount = productsList.filter(p => p.stock === 0).length;
|
||||
|
||||
// Filtered products for Tab 1
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = productsList;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(p => p.name.toLowerCase().includes(q) || p.sku.toLowerCase().includes(q));
|
||||
}
|
||||
if (statusFilter !== 'all') {
|
||||
if (statusFilter === 'ok') result = result.filter(p => p.stock > p.reorderPoint);
|
||||
if (statusFilter === 'low') result = result.filter(p => p.stock <= p.reorderPoint && p.stock > 0);
|
||||
if (statusFilter === 'oos') result = result.filter(p => p.stock === 0);
|
||||
}
|
||||
return result;
|
||||
}, [productsList, searchQuery, statusFilter]);
|
||||
|
||||
const handleAdjustConfirm = (productId: string, qty: number, type: string) => {
|
||||
setProductsList(prev => prev.map(p => {
|
||||
if (p.id === productId) {
|
||||
let newStock = p.stock;
|
||||
if (type === 'add') newStock += qty;
|
||||
else newStock -= qty; // remove, damage, write-off
|
||||
return { ...p, stock: newStock };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleManualAdjustmentSubmit = () => {
|
||||
if (!adjProduct) return toast.error('Select a product');
|
||||
if (!adjQty || Number(adjQty) <= 0) return toast.error('Enter valid quantity');
|
||||
|
||||
handleAdjustConfirm(adjProduct, Number(adjQty), adjType);
|
||||
setAdjProduct('');
|
||||
setAdjQty('');
|
||||
setAdjReason('');
|
||||
};
|
||||
|
||||
const renderStockLevels = () => (
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<div className="flex gap-4">
|
||||
<SearchBar
|
||||
placeholder="Search products..."
|
||||
className="w-80"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
className="w-48"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: 'ok', label: 'OK (In Stock)' },
|
||||
{ value: 'low', label: 'Low Stock' },
|
||||
{ value: 'oos', label: 'Out of Stock' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-white border border-gray-200 rounded-xl">
|
||||
<Table
|
||||
data={filteredProducts}
|
||||
columns={[
|
||||
{ key: 'name', label: 'Product', render: p => <span className="font-bold text-gray-900">{p.name}</span> },
|
||||
{ key: 'category', label: 'Category', render: p => <span className="text-gray-600">{categories.find(c => c.id === p.categoryId)?.name || '-'}</span> },
|
||||
{
|
||||
key: 'stock',
|
||||
label: 'On Hand',
|
||||
render: p => {
|
||||
let color = "text-gray-900";
|
||||
if (p.stock === 0) color = "text-red-600 font-bold";
|
||||
else if (p.stock <= p.reorderPoint) color = "text-amber-600 font-bold";
|
||||
return <span className={color}>{p.stock} {p.unit}</span>;
|
||||
}
|
||||
},
|
||||
{ key: 'reorder', label: 'Reorder Point', render: p => <span className="text-gray-500">{p.reorderPoint} {p.unit}</span> },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: p => {
|
||||
if (p.stock === 0) return <Badge variant="red">OOS</Badge>;
|
||||
if (p.stock <= p.reorderPoint) return <Badge variant="amber">Low</Badge>;
|
||||
return <Badge variant="green">OK</Badge>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
render: p => (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setSelectedProduct(p); setIsAdjustModalOpen(true); }}>Adjust</Button>
|
||||
{(p.stock <= p.reorderPoint) && (
|
||||
<Button size="sm" onClick={() => toast('Purchase order feature coming soon')}>Order</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStocktake = () => (
|
||||
<div className="grid grid-cols-2 gap-6 h-full">
|
||||
<Card title="Express Stocktake">
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center gap-3">
|
||||
<div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-full flex items-center justify-center shrink-0">
|
||||
<PackageCheck className="w-7 h-7" />
|
||||
</div>
|
||||
<p className="text-gray-500 max-w-sm text-sm">Quickly scan and count specific shelves or categories without locking down the entire store.</p>
|
||||
<Button className="mt-2" onClick={() => toast('Express stocktake coming soon')}>Start Express Count</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Advanced Stocktake">
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center gap-3">
|
||||
<div className="w-14 h-14 bg-primary/10 text-primary rounded-full flex items-center justify-center shrink-0">
|
||||
<ClipboardList className="w-7 h-7" />
|
||||
</div>
|
||||
<p className="text-gray-500 max-w-sm text-sm">Perform a full store audit. Freezes inventory movements until the count is reconciled and approved.</p>
|
||||
<Button className="mt-2" onClick={() => toast('Advanced stocktake coming soon')}>Start Full Audit</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAdjustments = () => (
|
||||
<div className="flex justify-center h-full pb-4">
|
||||
<Card title="New Stock Adjustment" className="w-[500px] h-fit shrink-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="Product"
|
||||
value={adjProduct}
|
||||
onChange={(e) => setAdjProduct(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Select a product...' },
|
||||
...productsList.map(p => ({ value: p.id, label: `${p.name} (Stock: ${p.stock})` }))
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Adjustment Type"
|
||||
value={adjType}
|
||||
onChange={(e) => setAdjType(e.target.value)}
|
||||
options={[
|
||||
{ value: 'add', label: 'Add Stock (+)' },
|
||||
{ value: 'remove', label: 'Remove Stock (-)' },
|
||||
{ value: 'damage', label: 'Damaged Goods (-)' },
|
||||
{ value: 'write_off', label: 'Write-off (-)' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label="Quantity"
|
||||
type="number"
|
||||
value={adjQty}
|
||||
onChange={(e) => setAdjQty(e.target.value ? Number(e.target.value) : '')}
|
||||
/>
|
||||
<Input
|
||||
label="Reason"
|
||||
placeholder="Why are you adjusting this stock?"
|
||||
value={adjReason}
|
||||
onChange={(e) => setAdjReason(e.target.value)}
|
||||
/>
|
||||
<Button className="mt-2" onClick={handleManualAdjustmentSubmit}>Submit Adjustment</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderHistory = () => (
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<div className="flex gap-4">
|
||||
<Input type="date" className="w-40" />
|
||||
<span className="self-center text-gray-500">to</span>
|
||||
<Input type="date" className="w-40" />
|
||||
</div>
|
||||
<div className="flex-1 bg-white border border-gray-200 rounded-xl overflow-auto shadow-sm">
|
||||
<Table
|
||||
data={mockHistory}
|
||||
columns={[
|
||||
{ key: 'date', label: 'Date / Time', render: h => <span className="text-gray-600">{h.date}</span> },
|
||||
{ key: 'product', label: 'Product', render: h => <span className="font-bold text-gray-900">{h.product}</span> },
|
||||
{ key: 'type', label: 'Type', render: h => <span className="font-medium">{h.type}</span> },
|
||||
{
|
||||
key: 'qtyChange',
|
||||
label: 'Qty Change',
|
||||
render: h => (
|
||||
<span className={`font-bold ${h.qtyChange > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{h.qtyChange > 0 ? '+' : ''}{h.qtyChange}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ key: 'reason', label: 'Reason', render: h => <span className="text-gray-500">{h.reason}</span> },
|
||||
{ key: 'user', label: 'User' },
|
||||
{ key: 'balance', label: 'Balance After', render: h => <span className="font-bold">{h.balance}</span> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 p-6 overflow-hidden gap-4">
|
||||
{/* STATS ROW */}
|
||||
<div className="grid grid-cols-4 gap-4 shrink-0">
|
||||
<StatCard
|
||||
label="Total SKUs"
|
||||
value={totalSKUs}
|
||||
icon={<Archive className="w-6 h-6" />}
|
||||
iconBg="bg-blue-100 text-blue-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Stock Value"
|
||||
value={formatCurrency(stockValue)}
|
||||
icon={<DollarSign className="w-6 h-6" />}
|
||||
iconBg="bg-green-100 text-green-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Low Stock Items"
|
||||
value={lowStockCount}
|
||||
icon={<AlertTriangle className="w-6 h-6" />}
|
||||
iconBg="bg-amber-100 text-amber-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Out of Stock"
|
||||
value={outOfStockCount}
|
||||
icon={<XCircle className="w-6 h-6" />}
|
||||
iconBg="bg-red-100 text-red-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TABS & CONTENT */}
|
||||
<div className="flex-1 flex flex-col bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden p-4 gap-4">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'levels', label: 'Stock Levels' },
|
||||
{ id: 'stocktake', label: 'Stocktake' },
|
||||
{ id: 'adjustments', label: 'Adjustments' },
|
||||
{ id: 'history', label: 'Movement History' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||
{activeTab === 'levels' && renderStockLevels()}
|
||||
{activeTab === 'stocktake' && renderStocktake()}
|
||||
{activeTab === 'adjustments' && renderAdjustments()}
|
||||
{activeTab === 'history' && renderHistory()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdjustStockModal
|
||||
isOpen={isAdjustModalOpen}
|
||||
onClose={() => setIsAdjustModalOpen(false)}
|
||||
product={selectedProduct}
|
||||
onConfirm={handleAdjustConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/pages/pos/CustomerIdentifyPanel.tsx
Normal file
237
src/pages/pos/CustomerIdentifyPanel.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, KeyboardEvent, ClipboardEvent } from 'react';
|
||||
import { Customer } from '@/types';
|
||||
import { customers } from '@/data/customers';
|
||||
import { useCartStore, WalkInCustomer } from '@/stores/cartStore';
|
||||
import { User, Search, UserPlus, Phone, Calendar, Mail, ArrowRight } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function CustomerIdentifyPanel() {
|
||||
const [phoneInput, setPhoneInput] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [foundCustomer, setFoundCustomer] = useState<Customer | null>(null);
|
||||
|
||||
// New customer form state
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newDob, setNewDob] = useState('');
|
||||
|
||||
const { setCustomer } = useCartStore();
|
||||
|
||||
const handleSearch = () => {
|
||||
if (phoneInput.length !== 10) {
|
||||
return toast.error('Please enter a valid 10-digit mobile number');
|
||||
}
|
||||
const customer = customers.find(c => c.phone === phoneInput);
|
||||
if (customer) {
|
||||
setFoundCustomer(customer);
|
||||
} else {
|
||||
setFoundCustomer(null);
|
||||
}
|
||||
setHasSearched(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const paste = e.clipboardData.getData('text').replace(/\D/g, '');
|
||||
if (paste.length >= 10) {
|
||||
const tenDigits = paste.slice(0, 10);
|
||||
setPhoneInput(tenDigits);
|
||||
setTimeout(() => {
|
||||
const customer = customers.find(c => c.phone === tenDigits);
|
||||
if (customer) setFoundCustomer(customer);
|
||||
else setFoundCustomer(null);
|
||||
setHasSearched(true);
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterAndStart = () => {
|
||||
if (!newName.trim()) {
|
||||
return toast.error('Name is required to register');
|
||||
}
|
||||
|
||||
const initials = newName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() || 'C';
|
||||
|
||||
const newCustomer: Customer = {
|
||||
id: `c${Date.now()}`,
|
||||
name: newName,
|
||||
phone: phoneInput,
|
||||
email: newEmail,
|
||||
dob: newDob,
|
||||
loyaltyPoints: 0,
|
||||
tier: 'silver',
|
||||
totalSpent: 0,
|
||||
storeCredit: 0,
|
||||
lastVisit: new Date().toISOString(),
|
||||
initials
|
||||
};
|
||||
|
||||
// Add to local state (mutates the imported array so it persists during session)
|
||||
customers.push(newCustomer);
|
||||
|
||||
setCustomer(newCustomer);
|
||||
toast.success('Customer registered successfully!');
|
||||
};
|
||||
|
||||
const startBill = () => {
|
||||
if (foundCustomer) {
|
||||
setCustomer(foundCustomer);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const resetSearch = () => {
|
||||
setHasSearched(false);
|
||||
setFoundCustomer(null);
|
||||
setPhoneInput('');
|
||||
setNewName('');
|
||||
setNewEmail('');
|
||||
setNewDob('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center p-4 min-h-0 bg-gray-50/50 overflow-hidden">
|
||||
<div className="bg-white rounded-2xl shadow-lg w-full max-w-[480px] p-5 my-auto animate-in fade-in zoom-in-95 duration-200">
|
||||
|
||||
<div className="text-center mb-3">
|
||||
<div className="w-10 h-10 bg-primary/20 text-primary rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-lg font-extrabold text-gray-900 mb-1">Who is this sale for?</h1>
|
||||
<p className="text-xs text-gray-500 font-medium">Enter customer mobile number to start billing</p>
|
||||
</div>
|
||||
|
||||
{!hasSearched ? (
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
placeholder="Enter 10-digit mobile number"
|
||||
className="w-full h-[44px] pl-10 pr-4 bg-gray-50 border-2 border-gray-200 rounded-xl text-[16px] font-mono tracking-wider focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/10 transition-all placeholder:text-gray-400 placeholder:text-sm placeholder:tracking-normal"
|
||||
value={phoneInput}
|
||||
onChange={(e) => setPhoneInput(e.target.value.replace(/\D/g, ''))}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="h-[44px] px-5 bg-primary text-white rounded-xl font-bold select-none touch-manipulation active:bg-primary/90 active:scale-95 transition-all flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
) : foundCustomer ? (
|
||||
/* CASE A: Customer FOUND */
|
||||
<div className="flex flex-col gap-6 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="border-2 border-green-500 bg-green-50 rounded-xl p-5 flex flex-col items-center text-center relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-green-500" />
|
||||
|
||||
<div className="w-16 h-16 bg-green-200 text-green-800 font-bold text-xl rounded-full flex items-center justify-center mb-3">
|
||||
{foundCustomer.initials}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-1">{foundCustomer.name}</h2>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="px-2 py-0.5 bg-primary/20 text-primary text-xs font-bold rounded uppercase tracking-wider">{foundCustomer.tier}</span>
|
||||
<span className="text-sm font-semibold text-gray-600">🎯 {foundCustomer.loyaltyPoints} pts</span>
|
||||
</div>
|
||||
|
||||
<div className="text-green-700 font-medium">Welcome back, {foundCustomer.name.split(' ')[0]}!</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={startBill}
|
||||
className="w-full h-[56px] bg-primary text-white rounded-xl font-bold text-lg select-none touch-manipulation active:bg-primary/90 active:scale-[0.98] transition-all flex items-center justify-center gap-2 shadow-md shadow-primary"
|
||||
>
|
||||
Start Bill <ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetSearch}
|
||||
className="w-full h-[44px] text-gray-500 font-medium rounded-lg active:bg-gray-100 select-none touch-manipulation transition-colors"
|
||||
>
|
||||
Not this customer? Search again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* CASE B: Customer NOT FOUND */
|
||||
<div className="flex flex-col gap-5 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="border-2 border-primary/40 bg-primary/5 rounded-xl p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-primary" />
|
||||
|
||||
<div className="flex items-center gap-3 mb-4 text-primary">
|
||||
<UserPlus className="w-6 h-6" />
|
||||
<h2 className="font-bold text-lg leading-none">New customer</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full Name *"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full h-[44px] px-4 bg-white border border-primary/20 rounded-lg focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 font-medium text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={phoneInput}
|
||||
readOnly
|
||||
className="w-full h-[44px] px-4 bg-gray-100 border border-gray-200 rounded-lg text-gray-500 font-mono text-sm"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email (Optional)"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
className="w-full h-[44px] pl-9 pr-3 bg-white border border-primary/20 rounded-lg focus:outline-none focus:border-primary font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={newDob}
|
||||
onChange={(e) => setNewDob(e.target.value)}
|
||||
className="w-full h-[44px] pl-9 pr-3 bg-white border border-primary/20 rounded-lg focus:outline-none focus:border-primary font-medium text-sm text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleRegisterAndStart}
|
||||
className="w-full h-[48px] bg-primary text-white rounded-xl font-bold text-lg select-none touch-manipulation active:bg-primary/90 active:scale-[0.98] transition-all flex items-center justify-center gap-2 shadow-md shadow-primary/20"
|
||||
>
|
||||
✓ Register & Start Bill
|
||||
</button>
|
||||
<button
|
||||
onClick={resetSearch}
|
||||
className="w-full h-[40px] text-gray-500 font-medium rounded-lg active:bg-gray-100 select-none touch-manipulation transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
389
src/pages/pos/POSPage.tsx
Normal file
389
src/pages/pos/POSPage.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { SearchBar, Badge, TopbarAction } from '@/components/ui';
|
||||
import { products } from '@/data/products';
|
||||
import { categories } from '@/data/categories';
|
||||
import { useCartStore } from '@/stores/cartStore';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import PaymentModal from './PaymentModal';
|
||||
import ReceiptModal from './ReceiptModal';
|
||||
import CustomerIdentifyPanel from './CustomerIdentifyPanel';
|
||||
import RefundModal from './RefundModal';
|
||||
import { ShoppingCart, X, Plus, Minus, RefreshCcw } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Sale } from '@/types';
|
||||
import { useBarcodeScanner } from '@/hooks/useBarcodeScanner';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export default function POSPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
|
||||
const [isRefundOpen, setIsRefundOpen] = useState(false);
|
||||
const [shakeCart, setShakeCart] = useState(false);
|
||||
const [completedSale, setCompletedSale] = useState<Sale | null>(null);
|
||||
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
const {
|
||||
activeCustomer, clearCustomer, clearCart,
|
||||
items, addItem, removeItem, updateQty,
|
||||
getSubtotal, getTaxAmount, getTotal,
|
||||
parkedSales, parkSale
|
||||
} = useCartStore();
|
||||
|
||||
const handlePaymentSuccess = (sale: Sale) => {
|
||||
setIsPaymentModalOpen(false);
|
||||
setCompletedSale(sale);
|
||||
};
|
||||
|
||||
const handleCloseReceipt = () => {
|
||||
setCompletedSale(null);
|
||||
clearCart();
|
||||
clearCustomer();
|
||||
};
|
||||
|
||||
useBarcodeScanner({
|
||||
onScan: (barcode) => {
|
||||
// Only allow scanning if a customer is identified and no modals are open
|
||||
if (!activeCustomer || isPaymentModalOpen || isRefundOpen || completedSale) return;
|
||||
|
||||
const product = products.find(p => p.barcode === barcode);
|
||||
if (product) {
|
||||
addItem(product);
|
||||
toast.success(`Added ${product.name}`, { duration: 1500, id: 'barcode-success' });
|
||||
} else {
|
||||
toast.error(`Barcode ${barcode} not found`, { duration: 2000, id: 'barcode-error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter((p) => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.barcode.includes(searchQuery);
|
||||
const matchesCategory = activeCategory === 'all' || p.categoryId === activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [searchQuery, activeCategory]);
|
||||
|
||||
const handleChangeCustomer = () => {
|
||||
if (items.length > 0) {
|
||||
if (window.confirm("Clear cart and change customer?")) {
|
||||
clearCart();
|
||||
}
|
||||
} else {
|
||||
clearCustomer();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChargeClick = () => {
|
||||
if (items.length === 0) {
|
||||
setShakeCart(true);
|
||||
setTimeout(() => setShakeCart(false), 500);
|
||||
return;
|
||||
}
|
||||
setIsPaymentModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePark = () => {
|
||||
if (items.length === 0) return;
|
||||
const parkId = `P${parkedSales.length + 1}`;
|
||||
parkSale();
|
||||
toast.success(`Bill parked as ${parkId}`);
|
||||
};
|
||||
|
||||
if (!activeCustomer) {
|
||||
return (
|
||||
<>
|
||||
<TopbarAction>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="font-bold text-white text-lg">🛒 POS Terminal</h1>
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/20 border border-green-500/30 text-green-100 rounded-full text-[10px] font-bold uppercase tracking-wider">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
{parkedSales.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-amber-500/20 border border-amber-500/30 text-amber-100 rounded-full text-xs font-bold flex items-center gap-1">
|
||||
<span>🅿</span> {parkedSales.length} parked
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsRefundOpen(true)}
|
||||
className="ml-2 h-[40px] px-4 rounded-lg border-2 border-white/20 text-white bg-primary font-bold text-sm select-none touch-manipulation active:bg-primary/90 transition-colors flex items-center gap-1 shadow-sm hover:bg-white/10"
|
||||
>
|
||||
🔄 Refund
|
||||
</button>
|
||||
</div>
|
||||
</TopbarAction>
|
||||
<CustomerIdentifyPanel />
|
||||
<RefundModal
|
||||
isOpen={isRefundOpen}
|
||||
onClose={() => setIsRefundOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isWalkIn = activeCustomer.id === 'walkin';
|
||||
const total = getTotal();
|
||||
const pointsToEarn = Math.floor(total / 10);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopbarAction>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="font-bold text-white text-lg">🛒 POS Terminal</h1>
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/20 border border-green-500/30 text-green-100 rounded-full text-[10px] font-bold uppercase tracking-wider">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
{parkedSales.length > 0 && (
|
||||
<span className="px-2 py-0.5 bg-amber-500/20 border border-amber-500/30 text-amber-100 rounded-full text-xs font-bold flex items-center gap-1">
|
||||
<span>🅿</span> {parkedSales.length} parked
|
||||
</span>
|
||||
)}
|
||||
{currentUser?.role === 'manager' && (
|
||||
<button
|
||||
onClick={() => setIsRefundOpen(true)}
|
||||
className="ml-2 h-[40px] px-4 rounded-lg border-2 border-white/20 text-white bg-primary font-bold text-sm select-none touch-manipulation active:bg-primary/90 transition-colors flex items-center gap-1 shadow-sm hover:bg-white/10"
|
||||
>
|
||||
🔄 Refund
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TopbarAction>
|
||||
|
||||
<div className="h-full grid grid-cols-[1fr_340px] overflow-hidden bg-gray-100">
|
||||
|
||||
{/* LEFT PANEL - Product Browser */}
|
||||
<div className="flex flex-col min-w-0 min-h-0 border-r border-gray-200 bg-white">
|
||||
|
||||
{/* Customer Header */}
|
||||
<div className="h-[56px] px-4 bg-gray-50 flex items-center justify-between shrink-0 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{!isWalkIn && 'initials' in activeCustomer ? (
|
||||
<div className="w-7 h-7 bg-primary/30 text-primary text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{activeCustomer.initials}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-7 h-7 bg-gray-200 text-gray-500 rounded-full flex items-center justify-center">
|
||||
<ShoppingCart className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-900">{activeCustomer.name}</span>
|
||||
{!isWalkIn && (
|
||||
<span className="px-1.5 py-0.5 bg-primary/20 text-primary text-[10px] font-bold rounded uppercase tracking-wider">
|
||||
{activeCustomer.tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleChangeCustomer}
|
||||
className="h-[36px] px-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 active:bg-gray-100 flex items-center gap-1.5 select-none touch-manipulation"
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4" /> Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search & Categories */}
|
||||
<div className="p-4 border-b border-gray-200 shrink-0">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="🔍 Search product, scan barcode or enter SKU…"
|
||||
className="h-[48px] text-base"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto py-2 mt-2" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
<button
|
||||
onClick={() => setActiveCategory('all')}
|
||||
className={`h-[44px] px-4 rounded-[22px] font-medium whitespace-nowrap select-none touch-manipulation active:scale-[0.97] transition-transform ${
|
||||
activeCategory === 'all' ? 'bg-primary text-white' : 'bg-white text-gray-700 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`h-[44px] px-4 rounded-[22px] font-medium whitespace-nowrap select-none touch-manipulation active:scale-[0.97] transition-transform ${
|
||||
activeCategory === cat.id ? 'bg-primary text-white' : 'bg-white text-gray-700 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{cat.emoji} {cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4 bg-gray-50">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(130px,1fr))] gap-3">
|
||||
{filteredProducts.map((p) => {
|
||||
const isOOS = p.stock === 0;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => !isOOS && addItem(p)}
|
||||
className={`bg-white border ${isOOS ? 'border-red-200 opacity-50 pointer-events-none' : 'border-gray-200'} rounded-xl min-h-[130px] p-3 flex flex-col select-none touch-manipulation transition-all ${isOOS ? '' : 'active:scale-[0.96] active:border-primary'}`}
|
||||
>
|
||||
<div className="text-[32px] text-center mb-1">{p.emoji}</div>
|
||||
<div className="text-[12px] font-medium text-gray-800 text-center leading-tight line-clamp-2 mb-auto">{p.name}</div>
|
||||
<div className="mt-2 text-center">
|
||||
<div className="text-[14px] font-bold text-primary">{formatCurrency(p.price)}</div>
|
||||
<div className="text-[11px] text-gray-500 mt-0.5">{p.stock} in stock</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredProducts.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-gray-500 font-medium">
|
||||
No products found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL - Cart */}
|
||||
<div className="bg-white flex flex-col min-h-0 shrink-0 shadow-[-4px_0_15px_-3px_rgba(0,0,0,0.05)] z-10 relative">
|
||||
<div className="h-[60px] px-4 flex items-center justify-between border-b border-gray-200 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-gray-900">Cart</h2>
|
||||
{items.length > 0 && (
|
||||
<Badge variant="blue">{items.reduce((acc, item) => acc + item.qty, 0)}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={handlePark}
|
||||
className="h-[36px] px-3 text-amber-600 border border-amber-300 font-bold text-sm rounded-lg active:bg-amber-50 select-none touch-manipulation flex items-center gap-1"
|
||||
>
|
||||
<span>🅿</span> Park
|
||||
</button>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="h-[36px] px-3 text-red-600 font-medium text-sm rounded-lg active:bg-red-50 select-none touch-manipulation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 min-h-0 overflow-y-auto p-4 flex flex-col ${shakeCart ? 'animate-[shake_0.5s_ease-in-out]' : ''}`}>
|
||||
{items.length === 0 ? (
|
||||
<div className="m-auto text-center flex flex-col items-center opacity-50">
|
||||
<ShoppingCart className="w-16 h-16 mb-4 text-gray-400" />
|
||||
<p className="text-gray-500 font-medium">Tap a product to add it</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product.id} className="flex gap-3 min-h-[56px] border-b border-gray-100 pb-3 last:border-0">
|
||||
<div className="text-2xl pt-1">{item.product.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 leading-tight mb-1">{item.product.name}</div>
|
||||
<div className="text-primary font-bold text-sm">{formatCurrency(item.product.price * item.qty)}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<button
|
||||
onClick={() => removeItem(item.product.id)}
|
||||
className="w-8 h-8 flex items-center justify-center text-gray-400 active:bg-red-50 active:text-red-600 rounded-md select-none touch-manipulation"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center bg-gray-50 rounded-lg border border-gray-200 mt-1">
|
||||
<button
|
||||
onClick={() => updateQty(item.product.id, item.qty - 1)}
|
||||
className="w-8 h-8 flex items-center justify-center active:bg-gray-200 rounded-l-lg select-none touch-manipulation"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-6 text-center font-semibold text-sm">{item.qty}</span>
|
||||
<button
|
||||
onClick={() => updateQty(item.product.id, item.qty + 1)}
|
||||
className="w-8 h-8 flex items-center justify-center active:bg-primary/20 active:text-primary rounded-r-lg select-none touch-manipulation"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col">
|
||||
{/* Points Earned Preview */}
|
||||
{!isWalkIn && items.length > 0 && (
|
||||
<div className="bg-green-50 px-4 py-2 text-center border-t border-green-100">
|
||||
<span className="text-xs font-bold text-green-700">🎯 This sale earns +{pointsToEarn} pts</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Subtotal</span>
|
||||
<span className="font-medium">{formatCurrency(getSubtotal())}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>GST (18%)</span>
|
||||
<span className="font-medium">{formatCurrency(getTaxAmount())}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-green-600 mb-3">
|
||||
<span>Discount</span>
|
||||
<span className="font-medium">-₹0.00</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end border-t-2 border-gray-200 pt-3 mb-4">
|
||||
<span className="font-bold text-gray-900">Total</span>
|
||||
<span className="text-2xl font-extrabold text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleChargeClick}
|
||||
disabled={items.length === 0}
|
||||
className={`w-full h-[58px] rounded-xl text-[18px] font-extrabold select-none touch-manipulation transition-transform shadow-md ${
|
||||
items.length === 0
|
||||
? 'bg-gray-300 text-gray-500 pointer-events-none shadow-none'
|
||||
: 'bg-primary text-white active:bg-primary/90 active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
CHARGE {items.length > 0 ? formatCurrency(total) : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentModal
|
||||
isOpen={isPaymentModalOpen}
|
||||
onClose={() => setIsPaymentModalOpen(false)}
|
||||
onPaymentSuccess={handlePaymentSuccess}
|
||||
/>
|
||||
|
||||
<ReceiptModal
|
||||
isOpen={completedSale !== null}
|
||||
onClose={handleCloseReceipt}
|
||||
sale={completedSale}
|
||||
/>
|
||||
|
||||
<RefundModal
|
||||
isOpen={isRefundOpen}
|
||||
onClose={() => setIsRefundOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
src/pages/pos/PaymentModal.tsx
Normal file
225
src/pages/pos/PaymentModal.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCartStore, PaymentMethod } from '@/stores/cartStore';
|
||||
import { customers } from '@/data/customers';
|
||||
import { sales } from '@/data/sales';
|
||||
import { Sale } from '@/types';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { X, Delete, Banknote, CreditCard, Smartphone, SplitSquareHorizontal } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface PaymentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onPaymentSuccess?: (sale: Sale) => void;
|
||||
}
|
||||
|
||||
export default function PaymentModal({ isOpen, onClose, onPaymentSuccess }: PaymentModalProps) {
|
||||
const { activeCustomer, items, getSubtotal, getTaxAmount, getTotal, paymentMethod, setPaymentMethod, clearCart } = useCartStore();
|
||||
const { currentUser } = useAuthStore();
|
||||
const [cashReceived, setCashReceived] = useState('');
|
||||
|
||||
const total = getTotal();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCashReceived('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || !activeCustomer) return null;
|
||||
|
||||
const isWalkIn = activeCustomer.id === 'walkin';
|
||||
const pointsToEarn = Math.floor(total / 10);
|
||||
|
||||
const cashAmount = parseFloat(cashReceived) || 0;
|
||||
const changeDue = Math.max(0, cashAmount - total);
|
||||
|
||||
const paymentMethods: { id: PaymentMethod, label: string, icon: React.ReactNode }[] = [
|
||||
{ id: 'cash', label: 'Cash', icon: <Banknote className="w-5 h-5 mb-1" /> },
|
||||
{ id: 'card', label: 'Card', icon: <CreditCard className="w-5 h-5 mb-1" /> },
|
||||
{ id: 'upi', label: 'UPI', icon: <Smartphone className="w-5 h-5 mb-1" /> },
|
||||
{ id: 'split', label: 'Split', icon: <SplitSquareHorizontal className="w-5 h-5 mb-1" /> },
|
||||
];
|
||||
|
||||
const handleNumpad = (val: string) => {
|
||||
if (val === 'Exact') {
|
||||
setCashReceived(total.toString());
|
||||
return;
|
||||
}
|
||||
if (val === 'backspace') {
|
||||
setCashReceived(prev => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate max amount
|
||||
if (cashReceived.length < 6) {
|
||||
// Prevent multiple leading zeros
|
||||
if (cashReceived === '0' && val === '0') return;
|
||||
if (cashReceived === '0' && val !== '0') {
|
||||
setCashReceived(val);
|
||||
return;
|
||||
}
|
||||
setCashReceived(prev => prev + val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (paymentMethod === 'cash' && cashAmount < total) {
|
||||
return toast.error('Received amount is less than the total due.');
|
||||
}
|
||||
|
||||
// Assign loyalty points if not a walk-in
|
||||
if (!isWalkIn) {
|
||||
const customerToUpdate = customers.find(c => c.id === activeCustomer.id);
|
||||
if (customerToUpdate) {
|
||||
customerToUpdate.loyaltyPoints += pointsToEarn;
|
||||
}
|
||||
}
|
||||
|
||||
// Record the sale
|
||||
const newSale: Sale = {
|
||||
id: `INV-2026-${1000 + sales.length + 1}`,
|
||||
type: 'sale',
|
||||
date: new Date().toISOString(),
|
||||
cashier: currentUser?.name || 'Unknown',
|
||||
customerId: isWalkIn ? 'walkin' : activeCustomer.id,
|
||||
items: items.map(i => ({
|
||||
productId: i.product.id,
|
||||
name: i.product.name,
|
||||
qty: i.qty,
|
||||
unitPrice: i.product.price
|
||||
})),
|
||||
subtotal: getSubtotal(),
|
||||
taxAmount: getTaxAmount(),
|
||||
discountAmount: 0,
|
||||
total: total,
|
||||
paymentMethod: paymentMethod
|
||||
};
|
||||
|
||||
sales.unshift(newSale);
|
||||
|
||||
const toastMsg = isWalkIn
|
||||
? '✅ Payment successful!'
|
||||
: `✅ Payment successful! +${pointsToEarn} pts added to ${activeCustomer.name.split(' ')[0]}`;
|
||||
|
||||
toast.success(toastMsg, { duration: 4000 });
|
||||
|
||||
// Call success callback and close modal (cart is cleared by Receipt modal or parent)
|
||||
if (onPaymentSuccess) {
|
||||
onPaymentSuccess(newSale);
|
||||
} else {
|
||||
clearCart();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const numpadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'Exact', '0', 'backspace'];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200 p-4">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="bg-white w-full max-w-sm rounded-2xl shadow-2xl relative z-10 flex flex-col overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-gray-500 uppercase tracking-wider">Billing</span>
|
||||
<span className="font-extrabold text-gray-900 text-lg">{activeCustomer.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white border border-gray-200 text-gray-500 active:bg-gray-100 touch-manipulation transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
|
||||
{/* Amount Due Box */}
|
||||
<div className="bg-primary/10 border-2 border-primary/30 rounded-xl p-4 flex flex-col items-center justify-center">
|
||||
<span className="text-primary font-bold text-sm mb-1 uppercase tracking-wider">Amount Due</span>
|
||||
<span className="text-3xl font-extrabold text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{paymentMethods.map((method) => {
|
||||
const isActive = paymentMethod === method.id;
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setPaymentMethod(method.id)}
|
||||
className={`h-[60px] flex flex-col items-center justify-center rounded-lg font-medium text-xs select-none touch-manipulation active:scale-[0.95] transition-all ${
|
||||
isActive
|
||||
? 'border-2 border-primary bg-primary/10 text-primary'
|
||||
: 'border border-gray-200 bg-gray-50 text-gray-600 active:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{method.icon}
|
||||
{method.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cash Input & Change */}
|
||||
{paymentMethod === 'cash' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<span className="text-sm font-bold text-gray-600">Received:</span>
|
||||
<span className={`text-xl font-bold font-mono ${cashReceived ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
₹{cashReceived || '0'}
|
||||
</span>
|
||||
</div>
|
||||
{cashAmount >= total && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-green-50 border-2 border-green-200 rounded-lg animate-in slide-in-from-top-2">
|
||||
<span className="text-sm font-bold text-green-700">Change Due:</span>
|
||||
<span className="text-lg font-extrabold text-green-700">{formatCurrency(changeDue)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Points preview */}
|
||||
{!isWalkIn && pointsToEarn > 0 && (
|
||||
<div className="bg-green-50/50 rounded-lg p-2 text-center border border-green-100">
|
||||
<p className="text-sm font-bold text-green-700">
|
||||
🎯 +{pointsToEarn} pts will be added to {activeCustomer.name.split(' ')[0]}'s account
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Numpad */}
|
||||
{paymentMethod === 'cash' && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{numpadKeys.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleNumpad(key)}
|
||||
className={`h-[56px] rounded-xl text-[20px] font-bold select-none touch-manipulation active:scale-[0.95] transition-transform ${
|
||||
key === 'Exact' ? 'bg-primary/20 text-primary active:bg-primary/30 text-base' :
|
||||
key === 'backspace' ? 'bg-gray-100 text-gray-600 active:bg-gray-200 flex items-center justify-center' :
|
||||
'bg-gray-50 border border-gray-200 text-gray-900 active:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{key === 'backspace' ? <Delete className="w-5 h-5" /> : key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="h-[56px] w-full bg-green-600 text-white rounded-xl font-bold text-[16px] select-none touch-manipulation active:bg-green-700 active:scale-[0.98] transition-all shadow-md mt-1"
|
||||
>
|
||||
✓ Confirm & Print Receipt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/pages/pos/ReceiptModal.tsx
Normal file
159
src/pages/pos/ReceiptModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Sale } from '@/types';
|
||||
import { X, Printer, Plus } from 'lucide-react';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { customers } from '@/data/customers';
|
||||
|
||||
interface ReceiptModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sale: Sale | null;
|
||||
}
|
||||
|
||||
export default function ReceiptModal({ isOpen, onClose, sale }: ReceiptModalProps) {
|
||||
if (!isOpen || !sale) return null;
|
||||
|
||||
const customer = sale.customerId && sale.customerId !== 'walkin'
|
||||
? customers.find(c => c.id === sale.customerId)
|
||||
: null;
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-white w-full max-w-[380px] rounded-2xl shadow-2xl relative flex flex-col overflow-hidden animate-in zoom-in-95 duration-300 max-h-[90vh]">
|
||||
|
||||
{/* Top Actions */}
|
||||
<div className="flex justify-between items-center p-3 border-b border-gray-100 bg-gray-50 shrink-0 print:hidden">
|
||||
<h3 className="font-bold text-gray-700">Transaction Complete</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 text-gray-500 hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Receipt Scroll Area */}
|
||||
<div className="flex-1 overflow-y-auto bg-[#f8f9fa] p-6 print:bg-white print:p-0">
|
||||
|
||||
{/* Actual Receipt Paper */}
|
||||
<div className="bg-white shadow-sm border border-gray-200 p-6 mx-auto w-full max-w-[320px] font-mono text-sm print:shadow-none print:border-none print:max-w-full">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold uppercase tracking-wider mb-1">Nearle Daily</h1>
|
||||
<p className="text-xs text-gray-500">123 Main Street, Market Area</p>
|
||||
<p className="text-xs text-gray-500">GSTIN: 29ABCDE1234F1Z5</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dashed border-gray-400 my-3"></div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="text-xs mb-3 flex flex-col gap-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Date: {new Date(sale.date).toLocaleDateString()}</span>
|
||||
<span>Time: {new Date(sale.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Bill: {sale.id}</span>
|
||||
<span>Cashier: {sale.cashier}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Customer:</span>
|
||||
<span className="font-bold text-right truncate max-w-[150px]">{customer ? customer.name : 'Walk-in'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dashed border-gray-400 my-3"></div>
|
||||
|
||||
{/* Items */}
|
||||
<table className="w-full text-xs mb-3">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-300">
|
||||
<th className="text-left pb-1">Item</th>
|
||||
<th className="text-center pb-1">Qty</th>
|
||||
<th className="text-right pb-1">Price</th>
|
||||
<th className="text-right pb-1">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sale.items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="py-1 pr-1 break-words">{item.name}</td>
|
||||
<td className="py-1 text-center align-top">{item.qty}</td>
|
||||
<td className="py-1 text-right align-top">{formatCurrency(item.unitPrice)}</td>
|
||||
<td className="py-1 text-right align-top">{formatCurrency(item.unitPrice * item.qty)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="border-t border-dashed border-gray-400 my-3"></div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="flex flex-col gap-1 text-xs mb-4">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatCurrency(sale.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>GST</span>
|
||||
<span>{formatCurrency(sale.taxAmount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-sm mt-1 pt-1 border-t border-gray-200">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(sale.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dashed border-gray-400 my-3"></div>
|
||||
|
||||
{/* Payment Info */}
|
||||
<div className="text-xs flex flex-col gap-1 text-center mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>Payment Method:</span>
|
||||
<span className="font-bold uppercase">{sale.paymentMethod}</span>
|
||||
</div>
|
||||
{customer && (
|
||||
<div className="flex justify-between mt-1 text-gray-600">
|
||||
<span>Points Earned:</span>
|
||||
<span className="font-bold">+{Math.floor(sale.total / 10)} pts</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-gray-500 mt-6">
|
||||
<p className="font-bold text-gray-800 mb-1">Thank you for shopping!</p>
|
||||
<p>Please visit again.</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
{/* Mock Barcode */}
|
||||
<div className="h-10 w-48 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPgo8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMTAgMTBoNHY4MGgtNHptNiAwaDJ2ODBIMTZ6bTQgMGg2djgwSDIwem04IDBoMnY4MEgyOHptNCAwaDR2ODBoLTR6bTYgMGgydjgwSDM4em00IDBoNnY4MEg0MnptOCAwaDJ2ODBINTB6bTQgMGg0djgwSDU0em02IDBoMnY4MEg2MHptNCAwaDZ2ODBINjR6IiBmaWxsPSJibGFjayIvPgo8L3N2Zz4=')] bg-repeat-x bg-contain opacity-50"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="p-4 bg-white border-t border-gray-200 flex gap-3 shrink-0 print:hidden">
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex-1 h-[48px] rounded-xl font-bold text-primary border-2 border-primary/20 bg-primary/5 active:bg-primary/10 select-none touch-manipulation transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Printer className="w-5 h-5" /> Print
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 h-[48px] rounded-xl font-bold text-white bg-primary active:bg-primary/90 select-none touch-manipulation transition-colors flex items-center justify-center gap-2 shadow-md shadow-primary/20"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> New Sale
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
588
src/pages/pos/RefundModal.tsx
Normal file
588
src/pages/pos/RefundModal.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Search, Phone, Receipt, RefreshCcw, CheckSquare, Square, Store, Smartphone, Banknote } from 'lucide-react';
|
||||
import { customers } from '@/data/customers';
|
||||
import { sales } from '@/data/sales';
|
||||
import { products } from '@/data/products';
|
||||
import { Sale, SaleItem } from '@/types';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
interface RefundModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type SearchType = 'phone' | 'bill';
|
||||
|
||||
export default function RefundModal({ isOpen, onClose }: RefundModalProps) {
|
||||
const { currentUser } = useAuthStore();
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [searchType, setSearchType] = useState<SearchType>('phone');
|
||||
|
||||
// Step 1 State
|
||||
const [phoneInput, setPhoneInput] = useState('');
|
||||
const [billInput, setBillInput] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Sale[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [selectedSale, setSelectedSale] = useState<Sale | null>(null);
|
||||
|
||||
// Step 2 State
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
|
||||
const [refundMethod, setRefundMethod] = useState<'cash' | 'card' | 'upi' | 'store_credit'>('cash');
|
||||
|
||||
// Step 3 State
|
||||
const [managerPin, setManagerPin] = useState('');
|
||||
const [pinError, setPinError] = useState(false);
|
||||
|
||||
// Reset state when closing
|
||||
const handleClose = () => {
|
||||
setStep(1);
|
||||
setSearchType('phone');
|
||||
setPhoneInput('');
|
||||
setBillInput('');
|
||||
setSearchResults([]);
|
||||
setHasSearched(false);
|
||||
setSelectedSale(null);
|
||||
setSelectedItemIds([]);
|
||||
setRefundMethod('cash');
|
||||
setManagerPin('');
|
||||
setPinError(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Search Handlers
|
||||
const handleSearch = () => {
|
||||
setHasSearched(true);
|
||||
if (searchType === 'phone') {
|
||||
const customer = customers.find(c => c.phone === phoneInput);
|
||||
if (customer) {
|
||||
// Find all completed sales for this customer that are not refunds
|
||||
const customerSales = sales.filter(s => s.customerId === customer.id && s.type !== 'refund');
|
||||
setSearchResults(customerSales.reverse());
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} else {
|
||||
const sale = sales.find(s => s.id === billInput.trim() && s.type !== 'refund');
|
||||
setSearchResults(sale ? [sale] : []);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSale = (sale: Sale) => {
|
||||
// Check if fully refunded
|
||||
const relatedRefunds = sales.filter(s => s.type === 'refund' && s.originalBillId === sale.id);
|
||||
const totalRefundedItems = relatedRefunds.reduce((acc, r) => acc + r.items.length, 0);
|
||||
|
||||
if (totalRefundedItems >= sale.items.length) {
|
||||
toast.error('This bill has already been fully refunded.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSale(sale);
|
||||
|
||||
// Auto-select items that haven't been refunded yet
|
||||
const alreadyRefundedItemIds = new Set(relatedRefunds.flatMap(r => r.items.map(i => i.productId)));
|
||||
const availableItems = sale.items.filter(i => !alreadyRefundedItemIds.has(i.productId));
|
||||
|
||||
setSelectedItemIds(availableItems.map(i => i.productId));
|
||||
|
||||
// Default refund method to original if applicable
|
||||
if (sale.paymentMethod !== 'split') {
|
||||
setRefundMethod(sale.paymentMethod as any);
|
||||
} else {
|
||||
setRefundMethod('cash');
|
||||
}
|
||||
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
// Step 2 Logic
|
||||
const toggleItem = (productId: string) => {
|
||||
setSelectedItemIds(prev =>
|
||||
prev.includes(productId) ? prev.filter(id => id !== productId) : [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllItems = (availableItems: SaleItem[]) => {
|
||||
if (selectedItemIds.length === availableItems.length) {
|
||||
setSelectedItemIds([]);
|
||||
} else {
|
||||
setSelectedItemIds(availableItems.map(i => i.productId));
|
||||
}
|
||||
};
|
||||
|
||||
const getRefundTotals = () => {
|
||||
if (!selectedSale) return { amount: 0, points: 0 };
|
||||
const refundItems = selectedSale.items.filter(i => selectedItemIds.includes(i.productId));
|
||||
const amount = refundItems.reduce((sum, item) => sum + (item.unitPrice * item.qty), 0);
|
||||
const points = Math.floor(amount / 10);
|
||||
return { amount, points };
|
||||
};
|
||||
|
||||
// Step 3 Logic
|
||||
const handleProcessRefund = () => {
|
||||
const { amount, points } = getRefundTotals();
|
||||
const PIN_THRESHOLD = 200;
|
||||
|
||||
if (amount >= PIN_THRESHOLD && managerPin !== '1234') {
|
||||
setPinError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSale) return;
|
||||
|
||||
const refundItems = selectedSale.items.filter(i => selectedItemIds.includes(i.productId));
|
||||
|
||||
// 1. Add qty back to stock
|
||||
refundItems.forEach(item => {
|
||||
const prod = products.find(p => p.id === item.productId);
|
||||
if (prod) {
|
||||
prod.stock += item.qty;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Deduct loyalty points (if applicable)
|
||||
let customerName = 'Walk-in';
|
||||
if (selectedSale.customerId && selectedSale.customerId !== 'walkin') {
|
||||
const customer = customers.find(c => c.id === selectedSale.customerId);
|
||||
if (customer) {
|
||||
customer.loyaltyPoints = Math.max(0, customer.loyaltyPoints - points);
|
||||
customerName = customer.name;
|
||||
|
||||
if (refundMethod === 'store_credit') {
|
||||
customer.storeCredit = (customer.storeCredit || 0) + amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record refund sale
|
||||
const newRefund: Sale = {
|
||||
id: `REF-${selectedSale.id}-${Date.now().toString().slice(-4)}`,
|
||||
type: 'refund',
|
||||
originalBillId: selectedSale.id,
|
||||
date: new Date().toISOString(),
|
||||
cashier: currentUser?.name || 'Unknown',
|
||||
customerId: selectedSale.customerId,
|
||||
items: refundItems,
|
||||
subtotal: -amount,
|
||||
taxAmount: 0,
|
||||
discountAmount: 0,
|
||||
total: -amount,
|
||||
paymentMethod: refundMethod
|
||||
};
|
||||
|
||||
sales.unshift(newRefund);
|
||||
|
||||
toast.success(`✅ Refund of ${formatCurrency(amount)} processed for ${customerName}`);
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// RENDER HELPERS
|
||||
// ---------------------------------------------------------
|
||||
|
||||
const renderStep1 = () => (
|
||||
<div className="flex flex-col h-full animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-extrabold text-gray-900 mb-2">🔄 Process Refund</h2>
|
||||
<p className="text-gray-500 font-medium">Search by customer phone or bill number</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => { setSearchType('phone'); setHasSearched(false); }}
|
||||
className={`flex-1 h-[44px] rounded-lg font-bold text-sm transition-colors flex items-center justify-center gap-2 ${searchType === 'phone' ? 'bg-primary/10 text-primary border-2 border-primary/20' : 'bg-gray-100 text-gray-600 border-2 border-transparent'}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" /> Customer Phone
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSearchType('bill'); setHasSearched(false); }}
|
||||
className={`flex-1 h-[44px] rounded-lg font-bold text-sm transition-colors flex items-center justify-center gap-2 ${searchType === 'bill' ? 'bg-primary/10 text-primary border-2 border-primary/20' : 'bg-gray-100 text-gray-600 border-2 border-transparent'}`}
|
||||
>
|
||||
<Receipt className="w-4 h-4" /> Bill Number
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
{searchType === 'phone' ? (
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
placeholder="Enter customer mobile number"
|
||||
value={phoneInput}
|
||||
onChange={(e) => setPhoneInput(e.target.value.replace(/\D/g, ''))}
|
||||
className="flex-1 h-[56px] px-4 bg-gray-50 border-2 border-gray-200 rounded-xl text-[20px] font-mono focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/10 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter bill number (e.g. INV-2026-1042)"
|
||||
value={billInput}
|
||||
onChange={(e) => setBillInput(e.target.value)}
|
||||
className="flex-1 h-[56px] px-4 bg-gray-50 border-2 border-gray-200 rounded-xl text-[20px] font-mono focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/10 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="h-[56px] px-6 bg-primary text-white rounded-xl font-bold select-none touch-manipulation active:bg-primary/90 transition-colors flex items-center gap-2 shadow-md"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!hasSearched ? (
|
||||
<div className="h-full flex items-center justify-center text-gray-400 font-medium">
|
||||
Enter search criteria to find sales
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 font-medium bg-gray-50 rounded-xl p-8 text-center border-2 border-dashed border-gray-200">
|
||||
{searchType === 'phone' ? 'No completed sales found for this customer.' : 'Bill not found.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 pb-4">
|
||||
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-1">Search Results</h3>
|
||||
{searchResults.map(sale => {
|
||||
const customer = sale.customerId ? customers.find(c => c.id === sale.customerId) : null;
|
||||
const isRefunded = sales.filter(s => s.type === 'refund' && s.originalBillId === sale.id).reduce((acc, r) => acc + r.items.length, 0) >= sale.items.length;
|
||||
|
||||
return (
|
||||
<div key={sale.id} className="min-h-[72px] border border-gray-200 rounded-xl p-4 flex items-center justify-between bg-white shadow-sm">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-bold text-gray-900">{sale.id}</span>
|
||||
<span className="text-gray-400 text-sm">•</span>
|
||||
<span className="text-sm text-gray-500">{new Date(sale.date).toLocaleDateString()}</span>
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-[10px] font-bold rounded uppercase ml-1">
|
||||
{sale.paymentMethod}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 flex items-center gap-1.5">
|
||||
<span className="font-medium text-gray-800">{customer?.name || 'Walk-in'}</span>
|
||||
<span className="text-gray-300">•</span>
|
||||
<span>{sale.items.reduce((acc, i) => acc + i.qty, 0)} items</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-lg font-bold text-gray-900">{formatCurrency(sale.total)}</div>
|
||||
<button
|
||||
onClick={() => handleSelectSale(sale)}
|
||||
disabled={isRefunded}
|
||||
className={`h-[44px] px-4 rounded-lg font-bold select-none touch-manipulation transition-colors ${
|
||||
isRefunded
|
||||
? 'bg-gray-100 text-gray-400 pointer-events-none'
|
||||
: 'bg-primary text-white active:bg-primary/90'
|
||||
}`}
|
||||
>
|
||||
{isRefunded ? 'Refunded' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStep2 = () => {
|
||||
if (!selectedSale) return null;
|
||||
const customer = selectedSale.customerId ? customers.find(c => c.id === selectedSale.customerId) : null;
|
||||
|
||||
// Check which items are already refunded
|
||||
const relatedRefunds = sales.filter(s => s.type === 'refund' && s.originalBillId === selectedSale.id);
|
||||
const alreadyRefundedItemIds = new Set(relatedRefunds.flatMap(r => r.items.map(i => i.productId)));
|
||||
const availableItems = selectedSale.items.filter(i => !alreadyRefundedItemIds.has(i.productId));
|
||||
const { amount, points } = getRefundTotals();
|
||||
|
||||
const refundMethods = [
|
||||
{ id: 'cash', label: 'Cash', icon: <Banknote className="w-4 h-4 mb-0.5" /> },
|
||||
{ id: 'upi', label: 'UPI', icon: <Smartphone className="w-4 h-4 mb-0.5" /> },
|
||||
{ id: 'store_credit', label: 'Store Credit', icon: <Store className="w-4 h-4 mb-0.5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="h-[44px] px-3 -ml-2 text-gray-500 active:bg-gray-100 rounded-lg font-medium flex items-center gap-1 select-none touch-manipulation transition-colors"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex-1 text-center pr-10">
|
||||
<h2 className="text-xl font-bold text-gray-900">Select Items</h2>
|
||||
<p className="text-xs text-gray-500 font-medium mt-0.5">{selectedSale.id} • {customer?.name || 'Walk-in'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-2 px-2">
|
||||
<span className="text-sm font-bold text-gray-600">Items in bill:</span>
|
||||
<button
|
||||
onClick={() => toggleAllItems(availableItems)}
|
||||
className="h-[44px] px-2 text-primary font-medium active:bg-primary/10 rounded-lg select-none touch-manipulation"
|
||||
>
|
||||
{selectedItemIds.length === availableItems.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-xl bg-white mb-4">
|
||||
{selectedSale.items.map((item) => {
|
||||
const isRefunded = alreadyRefundedItemIds.has(item.productId);
|
||||
const isSelected = selectedItemIds.includes(item.productId);
|
||||
const prod = products.find(p => p.id === item.productId);
|
||||
|
||||
return (
|
||||
<div key={item.productId} className={`min-h-[64px] border-b border-gray-100 last:border-0 flex items-center p-3 gap-3 ${isRefunded ? 'opacity-50 bg-gray-50' : ''}`}>
|
||||
<div
|
||||
onClick={() => !isRefunded && toggleItem(item.productId)}
|
||||
className={`w-11 h-11 flex items-center justify-center rounded-lg flex-shrink-0 cursor-pointer select-none touch-manipulation ${isRefunded ? '' : 'active:scale-95 transition-transform'}`}
|
||||
>
|
||||
{isRefunded ? (
|
||||
<div className="w-5 h-5 rounded border border-gray-300 bg-gray-200" />
|
||||
) : isSelected ? (
|
||||
<CheckSquare className="w-6 h-6 text-primary" />
|
||||
) : (
|
||||
<Square className="w-6 h-6 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{prod?.emoji}</span>
|
||||
<span className="text-base font-semibold text-gray-900 truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 ml-7">
|
||||
{item.qty} × {formatCurrency(item.unitPrice)}
|
||||
{isRefunded && <span className="ml-2 text-xs font-bold text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">Refunded</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-base font-bold text-gray-900 text-right pr-2">
|
||||
{formatCurrency(item.qty * item.unitPrice)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 bg-gray-50 rounded-xl p-4 border border-gray-200 mb-4">
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="text-gray-600 font-medium">Items to return:</span>
|
||||
<span className="font-bold text-gray-900">{selectedItemIds.length} of {availableItems.length}</span>
|
||||
</div>
|
||||
{points > 0 && customer && (
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-gray-600 font-medium">Loyalty points:</span>
|
||||
<span className="font-bold text-amber-600">-{points} pts</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center pt-2 border-t border-gray-200 mt-2 mb-4">
|
||||
<span className="text-gray-900 font-bold">Refund amount:</span>
|
||||
<span className="text-2xl font-extrabold text-primary">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-bold text-gray-500 uppercase tracking-wider block mb-2">Refund via:</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{refundMethods.map(method => (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setRefundMethod(method.id as any)}
|
||||
className={`h-[44px] rounded-lg font-medium text-sm flex items-center justify-center gap-1.5 select-none touch-manipulation active:scale-[0.97] transition-all ${
|
||||
refundMethod === method.id
|
||||
? 'bg-primary text-white shadow-md'
|
||||
: 'bg-white border border-gray-200 text-gray-600 active:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{method.icon}
|
||||
{method.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedSale.paymentMethod === 'card' && refundMethod !== 'store_credit' && (
|
||||
<p className="text-xs text-gray-500 mt-2 flex items-start gap-1">
|
||||
<span className="text-[10px]">ℹ️</span> Card refunds may take 3-5 days. You can refund as cash/store credit instead.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={selectedItemIds.length === 0}
|
||||
className={`h-[56px] w-full rounded-xl font-bold text-[18px] select-none touch-manipulation transition-all ${
|
||||
selectedItemIds.length === 0
|
||||
? 'bg-gray-300 text-gray-500 pointer-events-none'
|
||||
: 'bg-primary text-white active:bg-primary/90 active:scale-[0.98] shadow-md'
|
||||
}`}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep3 = () => {
|
||||
if (!selectedSale) return null;
|
||||
const customer = selectedSale.customerId ? customers.find(c => c.id === selectedSale.customerId) : null;
|
||||
const { amount, points } = getRefundTotals();
|
||||
const PIN_THRESHOLD = 200;
|
||||
const requiresPin = amount >= PIN_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
className="h-[44px] px-3 -ml-2 text-gray-500 active:bg-gray-100 rounded-lg font-medium flex items-center gap-1 select-none touch-manipulation transition-colors"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex-1 text-center pr-10">
|
||||
<h2 className="text-2xl font-extrabold text-gray-900">Confirm Refund</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 border-2 border-green-200 rounded-xl p-5 mb-6 flex flex-col gap-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-800/70 font-medium">Customer:</span>
|
||||
<span className="text-green-900 font-bold">{customer?.name || 'Walk-in'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-800/70 font-medium">Bill #:</span>
|
||||
<span className="text-green-900 font-bold">{selectedSale.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-800/70 font-medium">Items returned:</span>
|
||||
<span className="text-green-900 font-bold">{selectedItemIds.length} items</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-800/70 font-medium">Refund via:</span>
|
||||
<span className="text-green-900 font-bold uppercase">{refundMethod.replace('_', ' ')}</span>
|
||||
</div>
|
||||
{points > 0 && customer && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-800/70 font-medium">Points deducted:</span>
|
||||
<span className="text-green-900 font-bold">-{points} pts</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-t border-green-200 pt-3 mt-1">
|
||||
<span className="text-green-800 font-bold">Refund amount:</span>
|
||||
<span className="text-3xl font-extrabold text-green-600">{formatCurrency(amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
{requiresPin ? (
|
||||
<div className="w-full max-w-[280px] text-center">
|
||||
<label className="block text-sm font-bold text-gray-700 mb-3">
|
||||
Manager PIN required for refunds
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
placeholder="• • • •"
|
||||
value={managerPin}
|
||||
onChange={(e) => {
|
||||
setManagerPin(e.target.value.replace(/\D/g, ''));
|
||||
setPinError(false);
|
||||
}}
|
||||
className={`w-full h-[56px] text-center text-[28px] font-mono tracking-[0.5em] border-2 rounded-xl focus:outline-none transition-all bg-gray-50 ${
|
||||
pinError ? 'border-red-500 bg-red-50 text-red-600' : 'border-gray-300 focus:border-primary focus:bg-white'
|
||||
}`}
|
||||
autoFocus
|
||||
/>
|
||||
{pinError && (
|
||||
<p className="text-red-500 text-sm font-bold mt-2 animate-in slide-in-from-top-1">
|
||||
Incorrect PIN. Try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 font-medium">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-2xl">👍</span>
|
||||
</div>
|
||||
<p>Refund amount is under {formatCurrency(PIN_THRESHOLD)}.</p>
|
||||
<p>No Manager PIN required.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleProcessRefund}
|
||||
disabled={requiresPin && managerPin.length !== 4}
|
||||
className={`h-[56px] w-full rounded-xl font-bold text-[18px] select-none touch-manipulation transition-all flex items-center justify-center gap-2 ${
|
||||
requiresPin && managerPin.length !== 4
|
||||
? 'bg-gray-200 text-gray-400 pointer-events-none'
|
||||
: 'bg-primary text-white active:bg-primary/90 active:scale-[0.98] shadow-md shadow-primary/20'
|
||||
}`}
|
||||
>
|
||||
<RefreshCcw className="w-5 h-5" /> Process Refund
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-[48px] w-full rounded-xl font-bold text-[16px] text-gray-600 bg-white border border-gray-300 active:bg-gray-100 select-none touch-manipulation transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStep4 = () => (
|
||||
<div className="flex flex-col animate-in zoom-in-95 duration-300 items-center justify-center text-center px-4 py-6">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mb-6">
|
||||
<span className="text-4xl">✅</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-gray-900 mb-2">Refund Successful!</h2>
|
||||
<p className="text-gray-500 font-medium mb-8">
|
||||
The refund has been processed and recorded in the system.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-[56px] w-full rounded-xl font-bold text-[18px] text-white bg-primary active:bg-primary/90 select-none touch-manipulation transition-colors shadow-md"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className={`bg-white w-full rounded-2xl shadow-2xl relative flex flex-col overflow-hidden animate-in zoom-in-95 duration-300 transition-all ${
|
||||
step === 4 ? 'max-w-sm h-auto' : 'max-w-lg h-[90vh] max-h-[700px]'
|
||||
}`}>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-gray-50 border border-gray-200 text-gray-500 hover:bg-gray-100 touch-manipulation transition-colors z-10"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className={`p-6 flex flex-col ${step === 4 ? '' : 'flex-1 overflow-hidden'}`}>
|
||||
{step === 1 && renderStep1()}
|
||||
{step === 2 && renderStep2()}
|
||||
{step === 3 && renderStep3()}
|
||||
{step === 4 && renderStep4()}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
src/pages/products/ProductDrawer.tsx
Normal file
176
src/pages/products/ProductDrawer.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
import { Product } from '@/types';
|
||||
import { categories } from '@/data/categories';
|
||||
import { X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ProductDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
product?: Product | null;
|
||||
onSave: (product: Partial<Product>) => void;
|
||||
}
|
||||
|
||||
export default function ProductDrawer({ isOpen, onClose, product, onSave }: ProductDrawerProps) {
|
||||
const [formData, setFormData] = useState<Partial<Product>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (product) {
|
||||
setFormData({ ...product });
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
sku: '',
|
||||
barcode: '',
|
||||
categoryId: categories[0]?.id || '',
|
||||
price: 0,
|
||||
costPrice: 0,
|
||||
taxRate: 0,
|
||||
stock: 0,
|
||||
reorderPoint: 0,
|
||||
unit: 'pc',
|
||||
emoji: '📦',
|
||||
});
|
||||
}
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen, product]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleChange = (field: keyof Product, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name) newErrors.name = 'Name is required';
|
||||
if (!formData.sku) newErrors.sku = 'SKU is required';
|
||||
if (formData.price === undefined || formData.price < 0) newErrors.price = 'Valid price is required';
|
||||
if (formData.costPrice === undefined || formData.costPrice < 0) newErrors.costPrice = 'Valid cost price is required';
|
||||
if (formData.stock === undefined || formData.stock < 0) newErrors.stock = 'Valid stock is required';
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(formData);
|
||||
toast.success(product ? 'Product updated!' : 'Product created!');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="bg-white w-[400px] h-full shadow-2xl relative z-10 flex flex-col animate-in slide-in-from-right duration-300">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">{product ? 'Edit Product' : 'Add Product'}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center min-w-[48px] min-h-[48px] rounded-full bg-gray-100 text-gray-500 active:scale-[0.95] touch-manipulation"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
||||
<Input
|
||||
label="Product Name *"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="SKU *"
|
||||
value={formData.sku || ''}
|
||||
onChange={(e) => handleChange('sku', e.target.value)}
|
||||
error={errors.sku}
|
||||
/>
|
||||
<Input
|
||||
label="Barcode"
|
||||
value={formData.barcode || ''}
|
||||
onChange={(e) => handleChange('barcode', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Category"
|
||||
value={formData.categoryId || ''}
|
||||
onChange={(e) => handleChange('categoryId', e.target.value)}
|
||||
options={categories.map(c => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Price (₹) *"
|
||||
type="number"
|
||||
value={formData.price?.toString() || '0'}
|
||||
onChange={(e) => handleChange('price', parseFloat(e.target.value))}
|
||||
error={errors.price}
|
||||
/>
|
||||
<Input
|
||||
label="Cost Price (₹) *"
|
||||
type="number"
|
||||
value={formData.costPrice?.toString() || '0'}
|
||||
onChange={(e) => handleChange('costPrice', parseFloat(e.target.value))}
|
||||
error={errors.costPrice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Tax Rate"
|
||||
value={formData.taxRate?.toString() || '0'}
|
||||
onChange={(e) => handleChange('taxRate', parseFloat(e.target.value))}
|
||||
options={[0, 5, 12, 18, 28].map(t => ({ value: t, label: `${t}%` }))}
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
value={formData.unit || 'pc'}
|
||||
onChange={(e) => handleChange('unit', e.target.value)}
|
||||
options={['pc', 'kg', 'g', 'L', 'ml'].map(u => ({ value: u, label: u }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Current Stock *"
|
||||
type="number"
|
||||
value={formData.stock?.toString() || '0'}
|
||||
onChange={(e) => handleChange('stock', parseInt(e.target.value, 10))}
|
||||
error={errors.stock}
|
||||
/>
|
||||
<Input
|
||||
label="Reorder Point"
|
||||
type="number"
|
||||
value={formData.reorderPoint?.toString() || '0'}
|
||||
onChange={(e) => handleChange('reorderPoint', parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Emoji Icon"
|
||||
value={formData.emoji || ''}
|
||||
onChange={(e) => handleChange('emoji', e.target.value)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 bg-gray-50 shrink-0">
|
||||
<Button className="w-full" size="lg" onClick={handleSave}>
|
||||
Save Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/pages/products/ProductsPage.tsx
Normal file
222
src/pages/products/ProductsPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { SearchBar, Select, Button, Table, Badge } from '@/components/ui';
|
||||
import { products as initialProducts } from '@/data/products';
|
||||
import { categories } from '@/data/categories';
|
||||
import { Product } from '@/types';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import ProductDrawer from './ProductDrawer';
|
||||
import { Download, Plus } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [productsList, setProductsList] = useState<Product[]>(initialProducts);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [stockStatus, setStockStatus] = useState<string>('all');
|
||||
const [sortConfig, setSortConfig] = useState<{ key: keyof Product, direction: 'asc'|'desc' } | null>(null);
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsLoading(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = productsList;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.sku.toLowerCase().includes(q) ||
|
||||
p.barcode.includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (activeCategory !== 'all') {
|
||||
result = result.filter(p => p.categoryId === activeCategory);
|
||||
}
|
||||
|
||||
// Stock status filter
|
||||
if (stockStatus !== 'all') {
|
||||
if (stockStatus === 'in_stock') result = result.filter(p => p.stock > p.reorderPoint);
|
||||
if (stockStatus === 'low_stock') result = result.filter(p => p.stock <= p.reorderPoint && p.stock > 0);
|
||||
if (stockStatus === 'out_of_stock') result = result.filter(p => p.stock === 0);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (sortConfig) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[sortConfig.key];
|
||||
const bVal = b[sortConfig.key];
|
||||
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [productsList, searchQuery, activeCategory, stockStatus, sortConfig]);
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
const typedKey = key as keyof Product;
|
||||
let direction: 'asc' | 'desc' = 'asc';
|
||||
if (sortConfig && sortConfig.key === typedKey && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key: typedKey, direction });
|
||||
};
|
||||
|
||||
const handleEdit = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingProduct(null);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveProduct = (prodData: Partial<Product>) => {
|
||||
if (editingProduct) {
|
||||
setProductsList(prev => prev.map(p => p.id === editingProduct.id ? { ...p, ...prodData } as Product : p));
|
||||
} else {
|
||||
const newProduct = { ...prodData, id: `p${Date.now()}` } as Product;
|
||||
setProductsList(prev => [newProduct, ...prev]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 p-6 overflow-hidden gap-6">
|
||||
<div className="flex gap-4 items-end justify-between shrink-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<SearchBar
|
||||
className="w-80"
|
||||
placeholder="Search name, SKU, barcode..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
className="w-48"
|
||||
value={activeCategory}
|
||||
onChange={(e) => setActiveCategory(e.target.value)}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Categories' },
|
||||
...categories.map(c => ({ value: c.id, label: c.name }))
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
className="w-48"
|
||||
value={stockStatus}
|
||||
onChange={(e) => setStockStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Stock Status' },
|
||||
{ value: 'in_stock', label: 'In Stock' },
|
||||
{ value: 'low_stock', label: 'Low Stock' },
|
||||
{ value: 'out_of_stock', label: 'Out of Stock' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" icon={<Download className="w-4 h-4" />} onClick={() => toast('Import CSV feature coming soon')}>
|
||||
Import CSV
|
||||
</Button>
|
||||
<Button icon={<Plus className="w-4 h-4" />} onClick={handleAdd}>
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white border border-gray-200 rounded-xl flex flex-col overflow-hidden shadow-sm">
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex justify-between items-center bg-white shrink-0">
|
||||
<h2 className="font-bold text-gray-900">Product Catalog</h2>
|
||||
<span className="text-sm text-gray-500 font-medium">Showing {filteredProducts.length} of {productsList.length} products</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 animate-pulse rounded-lg w-full"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
data={filteredProducts}
|
||||
emptyMessage="No products match your filters."
|
||||
onSort={handleSort}
|
||||
sortConfig={sortConfig}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Product',
|
||||
sortable: true,
|
||||
render: (p) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{p.emoji}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-900">{p.name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{p.barcode}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'sku', label: 'SKU', render: (p) => <span className="font-mono text-sm text-gray-600">{p.sku}</span> },
|
||||
{ key: 'categoryId', label: 'Category', render: (p) => <span className="text-gray-600">{categories.find(c => c.id === p.categoryId)?.name || '-'}</span> },
|
||||
{
|
||||
key: 'price',
|
||||
label: 'Price',
|
||||
sortable: true,
|
||||
render: (p) => <span className="font-bold text-gray-900">{formatCurrency(p.price)}</span>
|
||||
},
|
||||
{ key: 'costPrice', label: 'Cost', render: (p) => <span className="text-gray-500">{formatCurrency(p.costPrice)}</span> },
|
||||
{
|
||||
key: 'stock',
|
||||
label: 'Stock',
|
||||
sortable: true,
|
||||
render: (p) => {
|
||||
let styling = "text-gray-900 font-medium";
|
||||
if (p.stock === 0) styling = "text-red-600 font-bold";
|
||||
else if (p.stock <= p.reorderPoint) styling = "text-amber-600 font-bold";
|
||||
return <span className={styling}>{p.stock} {p.unit}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (p) => {
|
||||
if (p.stock === 0) return <Badge variant="red">Out of Stock</Badge>;
|
||||
if (p.stock <= p.reorderPoint) return <Badge variant="amber">Low Stock</Badge>;
|
||||
return <Badge variant="green">In Stock</Badge>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
render: (p) => (
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(p)}>
|
||||
Edit
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
product={editingProduct}
|
||||
onSave={handleSaveProduct}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/pages/promotions/CreatePromotionDrawer.tsx
Normal file
180
src/pages/promotions/CreatePromotionDrawer.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
import { Promotion } from '@/types';
|
||||
import { X, Tag, Gift, Cake, CalendarClock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface CreatePromotionDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (promo: Promotion) => void;
|
||||
}
|
||||
|
||||
export default function CreatePromotionDrawer({ isOpen, onClose, onSave }: CreatePromotionDrawerProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState<Partial<Promotion>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep(1);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'percentage',
|
||||
value: 0,
|
||||
applyTo: 'All',
|
||||
status: 'active',
|
||||
icon: '🏷️',
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.name) return toast.error('Name is required');
|
||||
if (!formData.startDate || !formData.endDate) return toast.error('Dates are required');
|
||||
|
||||
const newPromo: Promotion = {
|
||||
id: `pr${Date.now()}`,
|
||||
name: formData.name,
|
||||
type: formData.type as 'percentage' | 'fixed' | 'buy_x_get_y',
|
||||
value: formData.value || 0,
|
||||
description: `${formData.type === 'percentage' ? formData.value + '% off' : 'Special promo'}`,
|
||||
applyTo: formData.applyTo || 'All',
|
||||
startDate: formData.startDate,
|
||||
endDate: formData.endDate,
|
||||
status: formData.status as 'active' | 'scheduled' | 'expired',
|
||||
usageCount: 0,
|
||||
icon: formData.icon || '🏷️',
|
||||
};
|
||||
|
||||
onSave(newPromo);
|
||||
toast.success('Promotion created successfully!');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const promoTypes = [
|
||||
{ id: 'percentage', label: 'Percentage Off', icon: <Tag className="w-6 h-6 text-blue-500" />, desc: 'e.g. 10% off all items', emoji: '🏷️' },
|
||||
{ id: 'buy_x_get_y', label: 'Buy X Get Y', icon: <Gift className="w-6 h-6 text-green-500" />, desc: 'e.g. Buy 2 Get 1 Free', emoji: '🎁' },
|
||||
{ id: 'fixed', label: 'Birthday Special', icon: <Cake className="w-6 h-6 text-amber-500" />, desc: 'e.g. ₹100 off on birthdays', emoji: '🎂' },
|
||||
{ id: 'scheduled', label: 'Scheduled/Seasonal', icon: <CalendarClock className="w-6 h-6 text-gray-500" />, desc: 'e.g. Happy Hour discounts', emoji: '⏰' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="bg-white w-[460px] h-full shadow-2xl relative z-10 flex flex-col animate-in slide-in-from-right duration-300">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">Create Promotion</h2>
|
||||
<button onClick={onClose} className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 text-gray-500 ">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
|
||||
{/* STEP 1 */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-5 animate-in fade-in slide-in-from-bottom-4">
|
||||
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider">Step 1: Basics</h3>
|
||||
<Input
|
||||
label="Promotion Name *"
|
||||
placeholder="e.g. Weekend Flash Sale"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-bold text-gray-700 mb-3 block">Promotion Type</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{promoTypes.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => setFormData({ ...formData, type: t.id, icon: t.emoji })}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all flex flex-col items-start gap-2 ${
|
||||
formData.type === t.id
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-gray-200 '
|
||||
}`}
|
||||
>
|
||||
{t.icon}
|
||||
<div className="font-bold text-gray-900 text-sm mt-1">{t.label}</div>
|
||||
<div className="text-xs text-gray-500 leading-tight">{t.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2 */}
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-5 animate-in fade-in slide-in-from-bottom-4">
|
||||
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider">Step 2: Rules</h3>
|
||||
|
||||
{formData.type === 'percentage' && (
|
||||
<>
|
||||
<Input label="Discount Value (%)" type="number" value={formData.value || ''} onChange={e => setFormData({ ...formData, value: Number(e.target.value) })} />
|
||||
<Select label="Apply To" value={formData.applyTo} onChange={e => setFormData({ ...formData, applyTo: e.target.value })} options={[
|
||||
{ value: 'All', label: 'All Products' },
|
||||
{ value: 'Snacks', label: 'Snacks Category' },
|
||||
{ value: 'Beverages', label: 'Beverages Category' },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{formData.type === 'buy_x_get_y' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Buy Quantity" type="number" placeholder="2" />
|
||||
<Input label="Get Quantity" type="number" placeholder="1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(formData.type === 'fixed' || formData.type === 'scheduled') && (
|
||||
<>
|
||||
<Input label="Discount Amount (₹)" type="number" value={formData.value || ''} onChange={e => setFormData({ ...formData, value: Number(e.target.value) })} />
|
||||
<Input label="Minimum Order Value" type="number" placeholder="0" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 3 */}
|
||||
{step === 3 && (
|
||||
<div className="flex flex-col gap-5 animate-in fade-in slide-in-from-bottom-4">
|
||||
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-wider">Step 3: Schedule & Limits</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Start Date" type="date" value={formData.startDate || ''} onChange={e => setFormData({ ...formData, startDate: e.target.value })} />
|
||||
<Input label="End Date" type="date" value={formData.endDate || ''} onChange={e => setFormData({ ...formData, endDate: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<Input label="Total Usage Limit" type="number" placeholder="Leave blank for unlimited" />
|
||||
|
||||
<Select label="Initial Status" value={formData.status} onChange={e => setFormData({ ...formData, status: e.target.value as any })} options={[
|
||||
{ value: 'active', label: 'Active Now' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 bg-gray-50 shrink-0 flex gap-3">
|
||||
{step > 1 ? (
|
||||
<Button variant="outline" className="flex-1" onClick={() => setStep(s => s - 1)}>Back</Button>
|
||||
) : (
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<Button className="flex-1" onClick={() => setStep(s => s + 1)}>Continue</Button>
|
||||
) : (
|
||||
<Button className="flex-1" onClick={handleSave}>Save Promotion</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/pages/promotions/PromotionsPage.tsx
Normal file
189
src/pages/promotions/PromotionsPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { StatCard, SearchBar, Select, Button, Badge } from '@/components/ui';
|
||||
import { promotions as initialPromotions } from '@/data/promotions';
|
||||
import { Promotion } from '@/types';
|
||||
import CreatePromotionDrawer from './CreatePromotionDrawer';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Tag, Sparkles, TrendingUp, CalendarDays, Plus } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function PromotionsPage() {
|
||||
const [promosList, setPromosList] = useState<Promotion[]>(initialPromotions);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
// Stats
|
||||
const activeCount = promosList.filter(p => p.status === 'active').length;
|
||||
const usedToday = promosList.reduce((acc, p) => acc + (p.status === 'active' ? Math.floor(p.usageCount / 10) : 0), 0) + 12; // mock today's usage
|
||||
const revenueImpact = 4500; // mock revenue impact
|
||||
const scheduledCount = promosList.filter(p => p.status === 'scheduled').length;
|
||||
|
||||
const filteredPromos = useMemo(() => {
|
||||
let result = promosList;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q));
|
||||
}
|
||||
if (statusFilter !== 'all') {
|
||||
result = result.filter(p => p.status === statusFilter);
|
||||
}
|
||||
return result;
|
||||
}, [promosList, searchQuery, statusFilter]);
|
||||
|
||||
const handleSavePromo = (promo: Promotion) => {
|
||||
setPromosList([promo, ...promosList]);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return <Badge variant="green">Active</Badge>;
|
||||
case 'scheduled': return <Badge variant="amber">Scheduled</Badge>;
|
||||
case 'expired': return <Badge variant="gray">Expired</Badge>;
|
||||
default: return <Badge variant="gray">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'percentage': return <span className="text-xs font-bold px-2 py-0.5 rounded-md bg-blue-100 text-blue-800">PERCENTAGE</span>;
|
||||
case 'buy_x_get_y': return <span className="text-xs font-bold px-2 py-0.5 rounded-md bg-green-100 text-green-800">BUY X GET Y</span>;
|
||||
case 'fixed': return <span className="text-xs font-bold px-2 py-0.5 rounded-md bg-amber-100 text-amber-800">FIXED AMOUNT</span>;
|
||||
case 'scheduled': return <span className="text-xs font-bold px-2 py-0.5 rounded-md bg-gray-100 text-gray-800">SEASONAL</span>;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getIconBg = (type: string) => {
|
||||
switch (type) {
|
||||
case 'percentage': return 'bg-blue-100 text-blue-600';
|
||||
case 'buy_x_get_y': return 'bg-green-100 text-green-600';
|
||||
case 'fixed': return 'bg-amber-100 text-amber-600';
|
||||
case 'scheduled': return 'bg-gray-100 text-gray-600';
|
||||
default: return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 p-6 overflow-hidden gap-6">
|
||||
|
||||
{/* STATS ROW */}
|
||||
<div className="grid grid-cols-4 gap-4 shrink-0">
|
||||
<StatCard
|
||||
label="Active Promotions"
|
||||
value={activeCount}
|
||||
icon={<Sparkles className="w-6 h-6" />}
|
||||
iconBg="bg-green-100 text-green-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Used Today"
|
||||
value={usedToday}
|
||||
change="+15%"
|
||||
changeType="up"
|
||||
icon={<Tag className="w-6 h-6" />}
|
||||
iconBg="bg-blue-100 text-blue-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Revenue Impact (MTD)"
|
||||
value={formatCurrency(revenueImpact)}
|
||||
icon={<TrendingUp className="w-6 h-6" />}
|
||||
iconBg="bg-purple-100 text-purple-700"
|
||||
/>
|
||||
<StatCard
|
||||
label="Scheduled Upcoming"
|
||||
value={scheduledCount}
|
||||
icon={<CalendarDays className="w-6 h-6" />}
|
||||
iconBg="bg-amber-100 text-amber-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<div className="flex-1 flex flex-col bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden p-6 gap-6">
|
||||
|
||||
<div className="flex gap-4 items-end justify-between shrink-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<SearchBar
|
||||
className="w-80"
|
||||
placeholder="Search promotions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
className="w-48"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'expired', label: 'Expired' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button icon={<Plus className="w-4 h-4" />} onClick={() => setIsDrawerOpen(true)}>
|
||||
New Promotion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPromos.map(p => {
|
||||
const opacity = p.status === 'expired' ? 'opacity-60 grayscale' : 'opacity-100';
|
||||
return (
|
||||
<div key={p.id} className={`p-5 rounded-xl border border-gray-200 shadow-sm flex flex-col transition-all ${opacity}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${getIconBg(p.type)}`}>
|
||||
{p.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 leading-tight">{p.name}</h3>
|
||||
<div className="mt-1">{getTypeBadge(p.type)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(p.status)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4 flex-1">{p.description}</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3 grid grid-cols-2 gap-2 mb-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-0.5">Valid Until</div>
|
||||
<div className="text-sm font-semibold text-gray-900">{new Date(p.endDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-0.5">Times Used</div>
|
||||
<div className="text-sm font-semibold text-gray-900">{p.usageCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-gray-100">
|
||||
<Button variant="outline" size="sm" className="flex-1" onClick={() => toast('Edit feature coming soon')}>Edit</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1 text-amber-600 border-amber-200 " onClick={() => toast('Pause feature coming soon')}>
|
||||
{p.status === 'active' ? 'Pause' : 'Activate'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredPromos.length === 0 && (
|
||||
<div className="col-span-full py-20 text-center flex flex-col items-center">
|
||||
<Tag className="w-12 h-12 text-gray-300 mb-4" />
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-1">No promotions found</h3>
|
||||
<p className="text-gray-500">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<CreatePromotionDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
onSave={handleSavePromo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
src/pages/reports/ReportsPage.tsx
Normal file
255
src/pages/reports/ReportsPage.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, StatCard, Button, Table, Badge } from '@/components/ui';
|
||||
import { sales } from '@/data/sales';
|
||||
import { dashboardData } from '@/data/dashboard';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { BarChart3, PackageOpen, FileText, Landmark, TrendingUp, Users, Download, Printer } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [activeReport, setActiveReport] = useState('sales');
|
||||
const [dateRange, setDateRange] = useState('today');
|
||||
|
||||
const reportTypes = [
|
||||
{ id: 'sales', label: 'Sales Report', icon: <BarChart3 className="w-5 h-5" />, active: true },
|
||||
{ id: 'inventory', label: 'Inventory Report', icon: <PackageOpen className="w-5 h-5" />, active: false },
|
||||
{ id: 'zreport', label: 'EOD Z-Report', icon: <FileText className="w-5 h-5" />, active: true, highlight: true },
|
||||
{ id: 'gst', label: 'GST Tax Report', icon: <Landmark className="w-5 h-5" />, active: false },
|
||||
{ id: 'profit', label: 'Profit & Margin', icon: <TrendingUp className="w-5 h-5" />, active: false },
|
||||
{ id: 'staff', label: 'Staff Performance', icon: <Users className="w-5 h-5" />, active: false },
|
||||
];
|
||||
|
||||
// Sales calculations
|
||||
const totalRevenue = dashboardData.todayStats.sales;
|
||||
const totalTransactions = dashboardData.todayStats.transactions;
|
||||
const gstCollected = totalRevenue * 0.18; // mockup
|
||||
const totalRefunds = 450; // mockup
|
||||
|
||||
const cashSales = 8500;
|
||||
const cardSales = 12000;
|
||||
const upiSales = totalRevenue - cashSales - cardSales;
|
||||
|
||||
const renderSalesChart = () => {
|
||||
const maxVal = Math.max(...dashboardData.weeklySales.map(s => s.amount));
|
||||
return (
|
||||
<div className="flex items-end justify-between h-48 mt-4 gap-2">
|
||||
{dashboardData.weeklySales.map((day, idx) => {
|
||||
const heightPct = (day.amount / maxVal) * 100;
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center gap-2 flex-1 group">
|
||||
<div className="relative w-full flex justify-center h-full items-end">
|
||||
{/* Tooltip */}
|
||||
<div className="absolute -top-10 bg-gray-900 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10">
|
||||
{day.day}: {formatCurrency(day.amount)}
|
||||
</div>
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full max-w-[40px] bg-primary/20 transition-colors rounded-t-md"
|
||||
style={{ height: `${heightPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-500">{day.day}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSalesReport = () => (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{['Today', 'Yesterday', 'This Week', 'This Month'].map(range => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setDateRange(range.toLowerCase())}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-semibold transition-colors ${
|
||||
dateRange === range.toLowerCase() ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-600 '
|
||||
}`}
|
||||
>
|
||||
{range}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" icon={<Download className="w-4 h-4" />} onClick={() => toast('Export feature coming soon')}>Export PDF</Button>
|
||||
<Button variant="outline" size="sm" icon={<Download className="w-4 h-4" />} onClick={() => toast('Export feature coming soon')}>Export Excel</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Total Revenue" value={formatCurrency(totalRevenue)} icon={<BarChart3 />} iconBg="bg-blue-100 text-blue-600" />
|
||||
<StatCard label="Transactions" value={totalTransactions} icon={<FileText />} iconBg="bg-green-100 text-green-600" />
|
||||
<StatCard label="GST Collected" value={formatCurrency(gstCollected)} icon={<Landmark />} iconBg="bg-amber-100 text-amber-600" />
|
||||
<StatCard label="Refunds" value={formatCurrency(totalRefunds)} icon={<TrendingUp />} iconBg="bg-red-100 text-red-600" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card className="col-span-2 p-6 flex flex-col">
|
||||
<h3 className="font-bold text-gray-900 mb-2">Revenue Trend</h3>
|
||||
{renderSalesChart()}
|
||||
</Card>
|
||||
|
||||
<Card title="Payment Breakdown" className="p-0 flex flex-col">
|
||||
<div className="p-6 flex flex-col gap-6 flex-1 justify-center">
|
||||
{[
|
||||
{ label: 'UPI', amount: upiSales, color: 'bg-orange-500' },
|
||||
{ label: 'Card', amount: cardSales, color: 'bg-blue-500' },
|
||||
{ label: 'Cash', amount: cashSales, color: 'bg-green-500' },
|
||||
].map(method => {
|
||||
const pct = (method.amount / totalRevenue) * 100;
|
||||
return (
|
||||
<div key={method.label}>
|
||||
<div className="flex justify-between text-sm font-semibold mb-1.5">
|
||||
<span>{method.label}</span>
|
||||
<span>{formatCurrency(method.amount)} <span className="text-gray-400 font-normal text-xs ml-1">({pct.toFixed(1)}%)</span></span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${method.color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Daily Breakdown">
|
||||
<Table
|
||||
data={[
|
||||
{ date: '2026-06-26', trans: 148, cash: cashSales, card: cardSales, upi: upiSales, total: totalRevenue, gst: gstCollected },
|
||||
{ date: '2026-06-25', trans: 132, cash: 7200, card: 11500, upi: 4000, total: 22700, gst: 4086 },
|
||||
{ date: '2026-06-24', trans: 145, cash: 8100, card: 9000, upi: 3500, total: 20600, gst: 3708 },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'date', label: 'Date', render: d => <span className="font-medium text-gray-900">{d.date}</span> },
|
||||
{ key: 'trans', label: 'Transactions' },
|
||||
{ key: 'cash', label: 'Cash', render: d => <span className="text-gray-600">{formatCurrency(d.cash)}</span> },
|
||||
{ key: 'card', label: 'Card', render: d => <span className="text-gray-600">{formatCurrency(d.card)}</span> },
|
||||
{ key: 'upi', label: 'UPI', render: d => <span className="text-gray-600">{formatCurrency(d.upi)}</span> },
|
||||
{ key: 'gst', label: 'GST (18%)', render: d => <span className="text-gray-500">{formatCurrency(d.gst)}</span> },
|
||||
{ key: 'total', label: 'Total Revenue', render: d => <span className="font-bold text-gray-900">{formatCurrency(d.total)}</span> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderZReport = () => (
|
||||
<div className="flex justify-center animate-in fade-in pb-10">
|
||||
<div className="w-[500px] bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-blue-600 p-6 text-white text-center">
|
||||
<h2 className="text-2xl font-extrabold uppercase tracking-widest mb-1">EOD Z-Report</h2>
|
||||
<p className="opacity-80 text-sm">Nearle Daily POS • 26 Jun 2026, 10:30 PM</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex flex-col gap-6">
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Register Summary</h3>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600 font-medium">Opening Float</span>
|
||||
<span className="font-bold">₹2,000.00</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600 font-medium">Cash Sales</span>
|
||||
<span className="font-bold">{formatCurrency(cashSales)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600 font-medium">Card Sales</span>
|
||||
<span className="font-bold">{formatCurrency(cardSales)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 font-medium">UPI Sales</span>
|
||||
<span className="font-bold">{formatCurrency(upiSales)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-200 pb-6">
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Revenue Breakdown</h3>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600 font-medium">Gross Sales</span>
|
||||
<span className="font-bold">{formatCurrency(totalRevenue)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600 font-medium">GST Collected</span>
|
||||
<span className="font-bold">{formatCurrency(gstCollected)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-gray-600 font-medium">Discounts Given</span>
|
||||
<span className="font-bold">-₹120.00</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 font-medium">Refunds</span>
|
||||
<span className="font-bold text-red-600">-₹{totalRefunds.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 flex justify-between items-center border border-gray-100">
|
||||
<span className="font-bold text-gray-900">Total Net Revenue</span>
|
||||
<span className="text-2xl font-extrabold text-blue-600">{formatCurrency(totalRevenue - totalRefunds)}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 text-green-800 rounded-lg p-4 flex justify-between items-center border border-green-200">
|
||||
<span className="font-bold">Expected Cash in Drawer</span>
|
||||
<span className="text-xl font-extrabold">{formatCurrency(2000 + cashSales)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-200">
|
||||
<Button className="w-full" size="lg" icon={<Printer className="w-5 h-5" />} onClick={() => window.print()}>
|
||||
Print Z-Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 p-6 overflow-hidden gap-6">
|
||||
|
||||
{/* REPORTS MENU GRID */}
|
||||
<div className="grid grid-cols-6 gap-4 shrink-0">
|
||||
{reportTypes.map(rt => {
|
||||
const isSelected = activeReport === rt.id;
|
||||
return (
|
||||
<div
|
||||
key={rt.id}
|
||||
onClick={() => setActiveReport(rt.id)}
|
||||
className={`p-4 rounded-xl border flex flex-col items-center justify-center gap-3 text-center cursor-pointer transition-all active:scale-[0.97] select-none touch-manipulation ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-sm text-primary'
|
||||
: 'border-gray-200 bg-white text-gray-600 '
|
||||
} ${rt.highlight && !isSelected ? 'border-blue-300 bg-blue-50/50' : ''}`}
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${isSelected ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{rt.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-sm font-bold leading-tight ${isSelected ? 'text-primary' : 'text-gray-900'}`}>{rt.label}</div>
|
||||
{!rt.active && <div className="text-[10px] font-bold text-amber-500 uppercase mt-1">Coming Soon</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* REPORT CONTENT */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeReport === 'sales' && renderSalesReport()}
|
||||
{activeReport === 'zreport' && renderZReport()}
|
||||
|
||||
{reportTypes.find(rt => rt.id === activeReport && !rt.active) && (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center animate-in fade-in">
|
||||
<div className="w-20 h-20 bg-gray-100 text-gray-400 rounded-full flex items-center justify-center mb-4">
|
||||
<FileText className="w-10 h-10" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Under Construction</h2>
|
||||
<p className="text-gray-500 max-w-md">The {reportTypes.find(rt => rt.id === activeReport)?.label} module is currently being built and will be available in the next major update.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
src/pages/settings/SettingsPage.tsx
Normal file
254
src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Input, Button, Select, Badge, Table, Modal } from '@/components/ui';
|
||||
import { Store, Users, Printer, FileText, CreditCard, Landmark, Bell, UploadCloud } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState('store');
|
||||
const [isEditUserOpen, setIsEditUserOpen] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'store', label: 'Store Profile', icon: <Store className="w-5 h-5" /> },
|
||||
{ id: 'users', label: 'Users & Roles', icon: <Users className="w-5 h-5" /> },
|
||||
{ id: 'hardware', label: 'Hardware', icon: <Printer className="w-5 h-5" /> },
|
||||
{ id: 'receipt', label: 'Receipt Template', icon: <FileText className="w-5 h-5" /> },
|
||||
{ id: 'payment', label: 'Payment Methods', icon: <CreditCard className="w-5 h-5" /> },
|
||||
{ id: 'tax', label: 'Tax Settings', icon: <Landmark className="w-5 h-5" /> },
|
||||
{ id: 'notifications', label: 'Notifications', icon: <Bell className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
const handleSave = () => toast.success('Changes saved successfully');
|
||||
|
||||
const renderStoreProfile = () => (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in max-w-3xl">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Store Profile</h2>
|
||||
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-40 h-40 border-2 border-dashed border-gray-300 rounded-2xl flex flex-col items-center justify-center text-gray-500 bg-gray-50 cursor-pointer transition-colors shrink-0">
|
||||
<UploadCloud className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm font-semibold">Upload Logo</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<Input label="Store Name" defaultValue="Nearle Daily" />
|
||||
<Input label="GST Number" defaultValue="29ABCDE1234F1Z5" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-bold text-gray-700">Store Address</label>
|
||||
<textarea
|
||||
className="w-full h-[100px] px-4 py-3 bg-white border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all text-[15px] resize-none"
|
||||
defaultValue="123 Main Street, Sector 4\nHSR Layout\nBangalore, 560102"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Phone Number" defaultValue="080-12345678" />
|
||||
<Input label="Email Address" defaultValue="contact@nearledaily.com" />
|
||||
<Select label="Currency" value="inr" options={[{value: 'inr', label: '₹ INR (Indian Rupee)'}]} />
|
||||
<Select label="Timezone" value="ist" options={[{value: 'ist', label: 'Asia/Kolkata (IST)'}]} />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderUsers = () => (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Users & Roles</h2>
|
||||
<Button size="sm" onClick={() => setIsEditUserOpen(true)}>Add Staff</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
data={[
|
||||
{ id: 1, name: 'Abhishek', role: 'manager', pin: '••••', status: 'active', lastLogin: 'Today, 09:15 AM' },
|
||||
{ id: 2, name: 'Prabhakaran', role: 'cashier', pin: '••••', status: 'active', lastLogin: 'Today, 10:30 AM' },
|
||||
{ id: 3, name: 'Kavitha', role: 'stock', pin: '••••', status: 'inactive', lastLogin: '2 days ago' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'name', label: 'Name', render: u => <span className="font-bold text-gray-900">{u.name}</span> },
|
||||
{ key: 'role', label: 'Role', render: u => {
|
||||
if (u.role === 'manager') return <Badge variant="blue">Manager</Badge>;
|
||||
if (u.role === 'cashier') return <Badge variant="green">Cashier</Badge>;
|
||||
return <Badge variant="amber">Stock Keeper</Badge>;
|
||||
}},
|
||||
{ key: 'pin', label: 'PIN' },
|
||||
{ key: 'status', label: 'Status', render: u => (
|
||||
<span className={`text-xs font-bold px-2 py-1 rounded-full ${u.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{u.status.toUpperCase()}
|
||||
</span>
|
||||
)},
|
||||
{ key: 'lastLogin', label: 'Last Login', render: u => <span className="text-gray-500 text-sm">{u.lastLogin}</span> },
|
||||
{ key: 'actions', label: '', render: () => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditUserOpen(true)}>Edit</Button>
|
||||
<Button variant="outline" size="sm" className="text-red-600 border-red-200 " onClick={() => toast('Status toggled')}>Deactivate</Button>
|
||||
</div>
|
||||
)}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={isEditUserOpen} onClose={() => setIsEditUserOpen(false)} title="Manage Staff">
|
||||
<div className="py-4 flex flex-col gap-4">
|
||||
<Input label="Name" defaultValue="New Staff" />
|
||||
<Select label="Role" value="cashier" options={[
|
||||
{value: 'manager', label: 'Manager'}, {value: 'cashier', label: 'Cashier'}, {value: 'stock', label: 'Stock Keeper'}
|
||||
]} />
|
||||
<Input label="4-Digit PIN" type="password" defaultValue="1234" />
|
||||
<label className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" className="w-5 h-5 rounded text-primary border-gray-300 focus:ring-primary" defaultChecked />
|
||||
<span className="font-medium text-gray-700">Account Active</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3 border-t border-gray-100 pt-4">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setIsEditUserOpen(false)}>Cancel</Button>
|
||||
<Button className="flex-1" onClick={() => { handleSave(); setIsEditUserOpen(false); }}>Save User</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderHardware = () => (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in max-w-4xl">
|
||||
<h2 className="text-xl font-bold text-gray-900">Hardware Configuration</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card title="Barcode Scanner" className="border-l-4 border-l-blue-500">
|
||||
<p className="text-gray-600">Plug USB scanner directly into the terminal. Scans will automatically route to the POS search bar. No driver installation needed.</p>
|
||||
<Badge variant="green" className="mt-4">Connected: COM3</Badge>
|
||||
</Card>
|
||||
|
||||
<Card title="Cash Drawer" className="border-l-4 border-l-green-500">
|
||||
<p className="text-gray-600">Triggered automatically on cash payment via the receipt printer RJ11 port.</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => toast.success('Drawer kicked')}>Test Open</Button>
|
||||
</Card>
|
||||
|
||||
<Card title="Thermal Printer" className="col-span-2 border-l-4 border-l-gray-800">
|
||||
<p className="text-gray-600 mb-4">Network or USB thermal receipt printer setup.</p>
|
||||
<div className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm">
|
||||
$ ping 192.168.1.100<br/>
|
||||
Reply from 192.168.1.100: bytes=32 time=2ms TTL=64<br/>
|
||||
[OK] Printer EPSON TM-T88VI responding on port 9100.
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4">
|
||||
<Input placeholder="IP Address (e.g. 192.168.1.100)" defaultValue="192.168.1.100" />
|
||||
<Button onClick={handleSave}>Connect</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderReceipt = () => (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in h-full">
|
||||
<h2 className="text-xl font-bold text-gray-900">Receipt Template</h2>
|
||||
|
||||
<div className="flex gap-8 items-start flex-1 min-h-0">
|
||||
<div className="flex-1 flex flex-col gap-4 overflow-y-auto pr-2">
|
||||
<Input label="Header Text" defaultValue="Nearle Daily\nThank you for shopping with us!" />
|
||||
<Input label="Footer Text" defaultValue="Visit again! www.nearledaily.com" />
|
||||
<Select label="Paper Size" value="80mm" options={[{value: '80mm', label: '80mm (Standard)'}, {value: '58mm', label: '58mm (Narrow)'}]} />
|
||||
|
||||
<div className="flex flex-col gap-3 p-4 bg-gray-50 border border-gray-200 rounded-xl mt-2">
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" defaultChecked />
|
||||
<span className="font-medium text-gray-700">Print Store Logo</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" defaultChecked />
|
||||
<span className="font-medium text-gray-700">Show GST Breakdown</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" className="w-5 h-5 rounded" defaultChecked />
|
||||
<span className="font-medium text-gray-700">Print Cashier Name</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button onClick={handleSave} className="mt-4">Save Template</Button>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="w-[350px] bg-gray-100 p-8 rounded-xl flex justify-center shadow-inner shrink-0 overflow-y-auto max-h-[600px]">
|
||||
<div className="w-[280px] bg-white p-6 shadow-sm font-mono text-[11px] leading-tight text-gray-800 border-t-4 border-gray-300">
|
||||
<div className="text-center font-bold text-base mb-2">NEARLE DAILY</div>
|
||||
<div className="text-center mb-4">Thank you for shopping with us!<br/>123 Main Street, Sector 4<br/>GST: 29ABCDE1234F1Z5</div>
|
||||
|
||||
<div className="flex justify-between border-b border-dashed border-gray-300 pb-2 mb-2">
|
||||
<span>Date: 26/06/2026</span>
|
||||
<span>Time: 10:30 AM</span>
|
||||
</div>
|
||||
|
||||
<table className="w-full mb-2">
|
||||
<tbody>
|
||||
<tr><td>Amul Milk 1L</td><td className="text-right">₹62.00</td></tr>
|
||||
<tr><td>Maggi 2-min</td><td className="text-right">₹14.00</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="border-t border-dashed border-gray-300 pt-2 mb-2">
|
||||
<div className="flex justify-between"><span>Subtotal</span><span>₹76.00</span></div>
|
||||
<div className="flex justify-between"><span>GST (18%)</span><span>₹13.68</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between font-bold text-sm border-t border-b border-gray-800 py-1 my-2">
|
||||
<span>TOTAL</span><span>₹89.68</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-6">Visit again! www.nearledaily.com</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderOther = (title: string) => (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in max-w-2xl">
|
||||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||
<Card className="p-6 text-gray-500">
|
||||
Settings for {title} will be configured here. (Mocked for demonstration).
|
||||
</Card>
|
||||
<Button onClick={handleSave} className="w-48">Save Settings</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-gray-50 overflow-hidden">
|
||||
|
||||
{/* SIDEBAR NAV */}
|
||||
<div className="w-[240px] bg-white border-r border-gray-200 shrink-0 flex flex-col p-4 gap-2 overflow-y-auto">
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-3">System Settings</h3>
|
||||
{tabs.map(tab => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-3 w-full p-3 rounded-xl font-medium transition-all select-none touch-manipulation active:scale-[0.98] ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-gray-600 '
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CONTENT PANEL */}
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
{activeTab === 'store' && renderStoreProfile()}
|
||||
{activeTab === 'users' && renderUsers()}
|
||||
{activeTab === 'hardware' && renderHardware()}
|
||||
{activeTab === 'receipt' && renderReceipt()}
|
||||
{['payment', 'tax', 'notifications'].includes(activeTab) && renderOther(tabs.find(t => t.id === activeTab)?.label || '')}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/pages/suppliers/AddSupplierModal.tsx
Normal file
56
src/pages/suppliers/AddSupplierModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Input, Button, Select } from '@/components/ui';
|
||||
import { Supplier } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AddSupplierModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (supplier: Supplier) => void;
|
||||
}
|
||||
|
||||
export default function AddSupplierModal({ isOpen, onClose, onSave }: AddSupplierModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
category: 'Grocery',
|
||||
phone: '',
|
||||
email: '',
|
||||
paymentTerms: 'Net 30'
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.name || !formData.phone) return toast.error('Name and Phone are required');
|
||||
onSave({
|
||||
id: `s${Date.now()}`,
|
||||
name: formData.name,
|
||||
category: formData.category,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
paymentTerms: formData.paymentTerms
|
||||
});
|
||||
toast.success('Supplier saved!');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Add New Supplier">
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<Input label="Supplier Name *" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
||||
<Select label="Category" value={formData.category} onChange={e => setFormData({...formData, category: e.target.value})} options={[
|
||||
{value: 'Dairy', label: 'Dairy'}, {value: 'Grocery', label: 'Grocery'}, {value: 'Beverages', label: 'Beverages'}
|
||||
]} />
|
||||
<Input label="Phone *" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} />
|
||||
<Input label="Email" type="email" value={formData.email} onChange={e => setFormData({...formData, email: e.target.value})} />
|
||||
<Select label="Payment Terms" value={formData.paymentTerms} onChange={e => setFormData({...formData, paymentTerms: e.target.value})} options={[
|
||||
{value: 'Net 7', label: 'Net 7 Days'}, {value: 'Net 15', label: 'Net 15 Days'}, {value: 'Net 30', label: 'Net 30 Days'}
|
||||
]} />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
<Button className="flex-1" onClick={handleSave}>Save Supplier</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
142
src/pages/suppliers/CreatePODrawer.tsx
Normal file
142
src/pages/suppliers/CreatePODrawer.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Input, Select } from '@/components/ui';
|
||||
import { PurchaseOrder, Supplier } from '@/types';
|
||||
import { X, Plus, Trash2 } from 'lucide-react';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface CreatePODrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
suppliers: Supplier[];
|
||||
initialSupplierId?: string;
|
||||
onSave: (po: PurchaseOrder) => void;
|
||||
}
|
||||
|
||||
export default function CreatePODrawer({ isOpen, onClose, suppliers, initialSupplierId, onSave }: CreatePODrawerProps) {
|
||||
const [supplierId, setSupplierId] = useState('');
|
||||
const [items, setItems] = useState<{name: string, qty: number, cost: number}[]>([]);
|
||||
const [newItem, setNewItem] = useState({ name: '', qty: '', cost: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSupplierId(initialSupplierId || '');
|
||||
setItems([]);
|
||||
setNewItem({ name: '', qty: '', cost: '' });
|
||||
}
|
||||
}, [isOpen, initialSupplierId]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!newItem.name || !newItem.qty || !newItem.cost) return;
|
||||
setItems([...items, { name: newItem.name, qty: Number(newItem.qty), cost: Number(newItem.cost) }]);
|
||||
setNewItem({ name: '', qty: '', cost: '' });
|
||||
};
|
||||
|
||||
const handleRemoveItem = (idx: number) => {
|
||||
setItems(items.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!supplierId) return toast.error('Select a supplier');
|
||||
if (items.length === 0) return toast.error('Add at least one item');
|
||||
|
||||
const supplier = suppliers.find(s => s.id === supplierId);
|
||||
|
||||
const newPO: PurchaseOrder = {
|
||||
id: `PO-${1000 + Math.floor(Math.random() * 9000)}`,
|
||||
supplierId,
|
||||
supplierName: supplier?.name || 'Unknown',
|
||||
value: items.reduce((acc, item) => acc + (item.qty * item.cost), 0),
|
||||
status: 'pending',
|
||||
orderedAt: new Date().toISOString(),
|
||||
items: items.map((item, idx) => ({
|
||||
productId: `new-${idx}`,
|
||||
name: item.name,
|
||||
qtyOrdered: item.qty,
|
||||
qtyReceived: 0,
|
||||
unitCost: item.cost,
|
||||
total: item.qty * item.cost
|
||||
}))
|
||||
};
|
||||
|
||||
onSave(newPO);
|
||||
toast.success('Purchase Order created');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const totalValue = items.reduce((acc, item) => acc + (item.qty * item.cost), 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="bg-white w-[500px] h-full shadow-2xl relative z-10 flex flex-col animate-in slide-in-from-right duration-300">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-xl font-bold text-gray-900">Create Purchase Order</h2>
|
||||
<button onClick={onClose} className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 text-gray-500 ">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
<Select
|
||||
label="Supplier *"
|
||||
value={supplierId}
|
||||
onChange={(e) => setSupplierId(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Select a supplier...' },
|
||||
...suppliers.map(s => ({ value: s.id, label: s.name }))
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="border border-gray-200 rounded-xl p-4 bg-gray-50 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-bold text-gray-700">Add Item</h3>
|
||||
<Input placeholder="Product name" value={newItem.name} onChange={e => setNewItem({...newItem, name: e.target.value})} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input type="number" placeholder="Quantity" value={newItem.qty} onChange={e => setNewItem({...newItem, qty: e.target.value})} />
|
||||
<Input type="number" placeholder="Unit Cost (₹)" value={newItem.cost} onChange={e => setNewItem({...newItem, cost: e.target.value})} />
|
||||
</div>
|
||||
<Button variant="outline" icon={<Plus className="w-4 h-4" />} onClick={handleAddItem}>Add to Order</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-bold text-gray-700">Order Items</h3>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4 border border-dashed rounded-lg">No items added yet</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center p-3 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{item.name}</div>
|
||||
<div className="text-xs text-gray-500">{item.qty} × {formatCurrency(item.cost)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="font-bold text-gray-900">{formatCurrency(item.qty * item.cost)}</div>
|
||||
<button onClick={() => handleRemoveItem(idx)} className="text-red-500 p-1">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 bg-white shrink-0 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<span className="font-bold text-gray-700">Total Value</span>
|
||||
<span className="font-extrabold text-gray-900">{formatCurrency(totalValue)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
<Button className="flex-1" onClick={handleSave}>Create PO</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/pages/suppliers/PODetailModal.tsx
Normal file
72
src/pages/suppliers/PODetailModal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Modal, Button } from '@/components/ui';
|
||||
import { PurchaseOrder } from '@/types';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { FileText } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface PODetailModalProps {
|
||||
po: PurchaseOrder | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PODetailModal({ po, onClose }: PODetailModalProps) {
|
||||
if (!po) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!po} onClose={onClose} title={`Purchase Order: ${po.id}`}>
|
||||
<div className="py-4 flex flex-col gap-6">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 font-bold uppercase mb-1">Supplier</div>
|
||||
<div className="font-semibold text-gray-900">{po.supplierName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 font-bold uppercase mb-1">Order Date</div>
|
||||
<div className="font-semibold text-gray-900">{new Date(po.orderedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 font-bold uppercase mb-1">Status</div>
|
||||
<div className="font-semibold text-gray-900 capitalize">{po.status.replace('_', ' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th className="p-3 font-semibold">Product</th>
|
||||
<th className="p-3 font-semibold text-right">Qty</th>
|
||||
<th className="p-3 font-semibold text-right">Cost</th>
|
||||
<th className="p-3 font-semibold text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{po.items.map((item, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 last:border-0">
|
||||
<td className="p-3 font-medium text-gray-900">
|
||||
{item.name}
|
||||
{po.status === 'received' && <div className="text-xs text-green-600">Received: {item.qtyReceived}</div>}
|
||||
</td>
|
||||
<td className="p-3 text-right text-gray-600">{item.qtyOrdered}</td>
|
||||
<td className="p-3 text-right text-gray-600">{formatCurrency(item.unitCost)}</td>
|
||||
<td className="p-3 text-right font-semibold text-gray-900">{formatCurrency(item.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t border-gray-200">
|
||||
<tr>
|
||||
<td colSpan={3} className="p-3 text-right font-bold text-gray-900">Total Order Value</td>
|
||||
<td className="p-3 text-right font-bold text-primary">{formatCurrency(po.value)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-100 justify-end">
|
||||
<Button variant="outline" onClick={() => toast('Print PO coming soon')} icon={<FileText className="w-4 h-4" />}>Print PO</Button>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
55
src/pages/suppliers/ReceiveStockModal.tsx
Normal file
55
src/pages/suppliers/ReceiveStockModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Button, Input } from '@/components/ui';
|
||||
import { PurchaseOrder } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ReceiveStockModalProps {
|
||||
po: PurchaseOrder | null;
|
||||
onClose: () => void;
|
||||
onReceive: (poId: string) => void;
|
||||
}
|
||||
|
||||
export default function ReceiveStockModal({ po, onClose, onReceive }: ReceiveStockModalProps) {
|
||||
if (!po) return null;
|
||||
|
||||
const handleReceive = () => {
|
||||
onReceive(po.id);
|
||||
toast.success(`Stock received for PO ${po.id}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!po} onClose={onClose} title={`Receive Stock: ${po.id}`}>
|
||||
<div className="py-4 flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-600">Please verify the quantities received against what was ordered from <strong>{po.supplierName}</strong>.</p>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th className="p-3 font-semibold">Product</th>
|
||||
<th className="p-3 font-semibold text-center">Ordered</th>
|
||||
<th className="p-3 font-semibold w-32">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{po.items.map((item, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 last:border-0">
|
||||
<td className="p-3 font-medium text-gray-900">{item.name}</td>
|
||||
<td className="p-3 text-center text-gray-600">{item.qtyOrdered}</td>
|
||||
<td className="p-3">
|
||||
<Input type="number" defaultValue={item.qtyOrdered} className="h-8 text-center" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
<Button className="flex-1" onClick={handleReceive}>Confirm Receipt</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
161
src/pages/suppliers/SuppliersPage.tsx
Normal file
161
src/pages/suppliers/SuppliersPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { StatCard, SearchBar, Button, Table, Badge } from '@/components/ui';
|
||||
import { suppliers as initialSuppliers, purchaseOrders as initialPOs } from '@/data/suppliers';
|
||||
import { Supplier, PurchaseOrder } from '@/types';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Truck, PackageOpen, FileText, ClipboardList, Plus, FileCheck, Eye } from 'lucide-react';
|
||||
import AddSupplierModal from './AddSupplierModal';
|
||||
import CreatePODrawer from './CreatePODrawer';
|
||||
import ReceiveStockModal from './ReceiveStockModal';
|
||||
import PODetailModal from './PODetailModal';
|
||||
|
||||
export default function SuppliersPage() {
|
||||
const [suppliersList, setSuppliersList] = useState<Supplier[]>(initialSuppliers);
|
||||
const [poList, setPoList] = useState<PurchaseOrder[]>(initialPOs);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
// Modal states
|
||||
const [isAddSupplierOpen, setIsAddSupplierOpen] = useState(false);
|
||||
const [isCreatePOOpen, setIsCreatePOOpen] = useState(false);
|
||||
const [selectedSupplierForPO, setSelectedSupplierForPO] = useState<string>('');
|
||||
|
||||
const [poToReceive, setPoToReceive] = useState<PurchaseOrder | null>(null);
|
||||
const [poToView, setPoToView] = useState<PurchaseOrder | null>(null);
|
||||
|
||||
// Derived Stats
|
||||
const activeSuppliers = suppliersList.length;
|
||||
const pendingCount = poList.filter(po => po.status === 'pending').length;
|
||||
const readyToReceive = poList.filter(po => po.status === 'in_transit').length;
|
||||
const openPoValue = poList.filter(po => po.status !== 'received').reduce((acc, po) => acc + po.value, 0);
|
||||
|
||||
const filteredSuppliers = useMemo(() => {
|
||||
return suppliersList.filter(s =>
|
||||
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [suppliersList, searchQuery]);
|
||||
|
||||
const filteredPOs = useMemo(() => {
|
||||
if (activeTab === 'all') return poList;
|
||||
return poList.filter(po => po.status === activeTab);
|
||||
}, [poList, activeTab]);
|
||||
|
||||
const handleAddSupplier = (supplier: Supplier) => {
|
||||
setSuppliersList([supplier, ...suppliersList]);
|
||||
};
|
||||
|
||||
const handleCreatePO = (po: PurchaseOrder) => {
|
||||
setPoList([po, ...poList]);
|
||||
};
|
||||
|
||||
const handleReceiveStock = (poId: string) => {
|
||||
setPoList(poList.map(po => po.id === poId ? { ...po, status: 'received' } : po));
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'received': return <Badge variant="green">Received</Badge>;
|
||||
case 'in_transit': return <Badge variant="blue">In Transit</Badge>;
|
||||
case 'pending': return <Badge variant="amber">Pending</Badge>;
|
||||
default: return <Badge variant="gray">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50 p-6 overflow-hidden gap-6">
|
||||
|
||||
{/* STATS ROW */}
|
||||
<div className="grid grid-cols-4 gap-4 shrink-0">
|
||||
<StatCard label="Active Suppliers" value={activeSuppliers} icon={<Truck />} iconBg="bg-blue-100 text-blue-700" />
|
||||
<StatCard label="Pending Orders" value={pendingCount} icon={<FileText />} iconBg="bg-amber-100 text-amber-700" />
|
||||
<StatCard label="Ready to Receive" value={readyToReceive} icon={<PackageOpen />} iconBg="bg-blue-100 text-blue-700" />
|
||||
<StatCard label="Open PO Value" value={formatCurrency(openPoValue)} icon={<ClipboardList />} iconBg="bg-purple-100 text-purple-700" />
|
||||
</div>
|
||||
|
||||
{/* TWO PANEL LAYOUT */}
|
||||
<div className="flex-1 flex gap-6 min-h-0">
|
||||
|
||||
{/* LEFT PANEL - Suppliers */}
|
||||
<div className="flex-[4] bg-white border border-gray-200 rounded-xl shadow-sm flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<Truck className="w-5 h-5 text-gray-400" /> Suppliers
|
||||
</h2>
|
||||
<Button size="sm" icon={<Plus className="w-4 h-4" />} onClick={() => setIsAddSupplierOpen(true)}>Add Supplier</Button>
|
||||
</div>
|
||||
<div className="p-4 border-b border-gray-100 shrink-0">
|
||||
<SearchBar placeholder="Search suppliers..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Table
|
||||
data={filteredSuppliers}
|
||||
columns={[
|
||||
{ key: 'name', label: 'Name', render: s => <span className="font-bold text-gray-900">{s.name}</span> },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'contact', label: 'Contact', render: s => <span className="text-sm text-gray-600">{s.phone}</span> },
|
||||
{ key: 'terms', label: 'Terms', render: s => <span className="text-xs font-semibold px-2 py-1 bg-gray-100 rounded-md">{s.paymentTerms}</span> },
|
||||
{ key: 'action', label: '', render: s => (
|
||||
<Button variant="outline" size="sm" onClick={() => { setSelectedSupplierForPO(s.id); setIsCreatePOOpen(true); }}>
|
||||
Order
|
||||
</Button>
|
||||
)}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL - Purchase Orders */}
|
||||
<div className="flex-[6] bg-white border border-gray-200 rounded-xl shadow-sm flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<ClipboardList className="w-5 h-5 text-gray-400" /> Purchase Orders
|
||||
</h2>
|
||||
<Button size="sm" icon={<Plus className="w-4 h-4" />} onClick={() => { setSelectedSupplierForPO(''); setIsCreatePOOpen(true); }}>New PO</Button>
|
||||
</div>
|
||||
<div className="p-4 border-b border-gray-100 shrink-0 flex gap-2 overflow-x-auto" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{['all', 'pending', 'in_transit', 'received'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-semibold capitalize whitespace-nowrap transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-gray-600 '
|
||||
}`}
|
||||
>
|
||||
{tab.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Table
|
||||
data={filteredPOs}
|
||||
columns={[
|
||||
{ key: 'id', label: 'PO#', render: p => <span className="font-bold text-gray-900">{p.id}</span> },
|
||||
{ key: 'supplierName', label: 'Supplier', render: p => <span className="font-medium truncate max-w-[120px] block">{p.supplierName}</span> },
|
||||
{ key: 'items', label: 'Items', render: p => p.items.length },
|
||||
{ key: 'value', label: 'Value', render: p => <span className="font-bold">{formatCurrency(p.value)}</span> },
|
||||
{ key: 'status', label: 'Status', render: p => getStatusBadge(p.status) },
|
||||
{ key: 'action', label: '', render: p => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
{p.status === 'in_transit' && (
|
||||
<Button variant="outline" size="sm" className="text-blue-600 border-blue-200 " icon={<FileCheck className="w-4 h-4" />} onClick={() => setPoToReceive(p)}>Receive</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" icon={<Eye className="w-4 h-4" />} onClick={() => setPoToView(p)}>View</Button>
|
||||
</div>
|
||||
)}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<AddSupplierModal isOpen={isAddSupplierOpen} onClose={() => setIsAddSupplierOpen(false)} onSave={handleAddSupplier} />
|
||||
<CreatePODrawer isOpen={isCreatePOOpen} onClose={() => setIsCreatePOOpen(false)} suppliers={suppliersList} initialSupplierId={selectedSupplierForPO} onSave={handleCreatePO} />
|
||||
<ReceiveStockModal po={poToReceive} onClose={() => setPoToReceive(null)} onReceive={handleReceiveStock} />
|
||||
<PODetailModal po={poToView} onClose={() => setPoToView(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/stores/authStore.ts
Normal file
30
src/stores/authStore.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { User } from '@/types';
|
||||
import { staff } from '@/data/staff';
|
||||
|
||||
interface AuthState {
|
||||
currentUser: User | null;
|
||||
login: (pin: string) => boolean;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentUser: null,
|
||||
login: (pin: string) => {
|
||||
const user = staff.find(u => u.pin === pin);
|
||||
if (user) {
|
||||
set({ currentUser: user });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
logout: () => set({ currentUser: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
147
src/stores/cartStore.ts
Normal file
147
src/stores/cartStore.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { create } from 'zustand';
|
||||
import { Product, Customer } from '../types';
|
||||
|
||||
export interface WalkInCustomer {
|
||||
id: 'walkin';
|
||||
name: 'Walk-in Customer';
|
||||
phone: '';
|
||||
tier: 'none';
|
||||
loyaltyPoints: 0;
|
||||
}
|
||||
|
||||
export type PaymentMethod = 'cash' | 'card' | 'upi' | 'split' | 'store_credit';
|
||||
|
||||
export interface ParkedSale {
|
||||
id: string;
|
||||
customer: Customer | WalkInCustomer;
|
||||
items: CartItem[];
|
||||
subtotal: number;
|
||||
total: number;
|
||||
parkedAt: string;
|
||||
paymentMethod: PaymentMethod;
|
||||
discount: number;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
product: Product;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
activeCustomer: Customer | WalkInCustomer | null;
|
||||
items: CartItem[];
|
||||
paymentMethod: PaymentMethod;
|
||||
discount: number;
|
||||
parkedSales: ParkedSale[];
|
||||
setCustomer: (customer: Customer | WalkInCustomer) => void;
|
||||
clearCustomer: () => void;
|
||||
addItem: (product: Product) => void;
|
||||
removeItem: (productId: string) => void;
|
||||
updateQty: (productId: string, qty: number) => void;
|
||||
clearCart: () => void;
|
||||
parkSale: () => void;
|
||||
retrieveSale: (id: string) => void;
|
||||
setPaymentMethod: (method: PaymentMethod) => void;
|
||||
setDiscount: (amount: number) => void;
|
||||
// Computed values
|
||||
getSubtotal: () => number;
|
||||
getTaxAmount: () => number;
|
||||
getTotal: () => number;
|
||||
getItemCount: () => number;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>((set, get) => ({
|
||||
activeCustomer: null,
|
||||
items: [],
|
||||
paymentMethod: 'cash',
|
||||
discount: 0,
|
||||
parkedSales: [],
|
||||
|
||||
setCustomer: (customer) => set({ activeCustomer: customer }),
|
||||
clearCustomer: () => set({ activeCustomer: null }),
|
||||
|
||||
addItem: (product: Product) => set((state) => {
|
||||
const existing = state.items.find(i => i.product.id === product.id);
|
||||
if (existing) {
|
||||
return {
|
||||
items: state.items.map(i =>
|
||||
i.product.id === product.id ? { ...i, qty: i.qty + 1 } : i
|
||||
)
|
||||
};
|
||||
}
|
||||
return { items: [...state.items, { product, qty: 1 }] };
|
||||
}),
|
||||
|
||||
removeItem: (productId: string) => set((state) => ({
|
||||
items: state.items.filter(i => i.product.id !== productId)
|
||||
})),
|
||||
|
||||
updateQty: (productId: string, qty: number) => set((state) => ({
|
||||
items: qty <= 0
|
||||
? state.items.filter(i => i.product.id !== productId)
|
||||
: state.items.map(i => i.product.id === productId ? { ...i, qty } : i)
|
||||
})),
|
||||
|
||||
clearCart: () => set({ items: [], discount: 0, paymentMethod: 'cash', activeCustomer: null }),
|
||||
|
||||
parkSale: () => set((state) => {
|
||||
if (!state.activeCustomer || state.items.length === 0) return state;
|
||||
|
||||
// Calculate totals for parked representation
|
||||
const subtotal = state.items.reduce((sum, item) => sum + (item.product.price * item.qty), 0);
|
||||
const taxAmount = state.items.reduce((sum, item) => sum + ((item.product.price * item.qty) * (item.product.taxRate / 100)), 0);
|
||||
const total = subtotal + taxAmount - state.discount;
|
||||
|
||||
const newParkedSale: ParkedSale = {
|
||||
id: `P${state.parkedSales.length + 1}`,
|
||||
customer: state.activeCustomer,
|
||||
items: [...state.items],
|
||||
subtotal,
|
||||
total,
|
||||
parkedAt: new Date().toISOString(),
|
||||
paymentMethod: state.paymentMethod,
|
||||
discount: state.discount
|
||||
};
|
||||
|
||||
return {
|
||||
parkedSales: [...state.parkedSales, newParkedSale],
|
||||
items: [],
|
||||
discount: 0,
|
||||
activeCustomer: null,
|
||||
paymentMethod: 'cash'
|
||||
};
|
||||
}),
|
||||
|
||||
retrieveSale: (id: string) => set((state) => {
|
||||
const saleToRetrieve = state.parkedSales.find(p => p.id === id);
|
||||
if (!saleToRetrieve) return state;
|
||||
|
||||
return {
|
||||
parkedSales: state.parkedSales.filter(p => p.id !== id),
|
||||
activeCustomer: saleToRetrieve.customer,
|
||||
items: saleToRetrieve.items,
|
||||
paymentMethod: saleToRetrieve.paymentMethod,
|
||||
discount: saleToRetrieve.discount
|
||||
};
|
||||
}),
|
||||
|
||||
setPaymentMethod: (paymentMethod: PaymentMethod) => set({ paymentMethod }),
|
||||
|
||||
setDiscount: (discount: number) => set({ discount }),
|
||||
|
||||
getSubtotal: () => {
|
||||
return get().items.reduce((sum, item) => sum + (item.product.price * item.qty), 0);
|
||||
},
|
||||
|
||||
getTaxAmount: () => {
|
||||
return get().items.reduce((sum, item) => sum + ((item.product.price * item.qty) * (item.product.taxRate / 100)), 0);
|
||||
},
|
||||
|
||||
getTotal: () => {
|
||||
return get().getSubtotal() + get().getTaxAmount() - get().discount;
|
||||
},
|
||||
|
||||
getItemCount: () => {
|
||||
return get().items.reduce((count, item) => count + item.qty, 0);
|
||||
}
|
||||
}));
|
||||
140
src/types/index.ts
Normal file
140
src/types/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'cashier' | 'manager';
|
||||
pin: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
barcode: string;
|
||||
price: number;
|
||||
costPrice: number;
|
||||
taxRate: number;
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
unit: string;
|
||||
emoji: string;
|
||||
stock: number;
|
||||
reorderPoint: number;
|
||||
status: 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
dob: string;
|
||||
loyaltyPoints: number;
|
||||
tier: 'silver' | 'gold' | 'platinum';
|
||||
totalSpent: number;
|
||||
storeCredit: number;
|
||||
lastVisit: string;
|
||||
initials: string;
|
||||
}
|
||||
|
||||
export interface SaleItem {
|
||||
productId: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface Sale {
|
||||
id: string;
|
||||
type?: 'sale' | 'refund';
|
||||
originalBillId?: string;
|
||||
date: string;
|
||||
cashier: string;
|
||||
customerId?: string;
|
||||
items: SaleItem[];
|
||||
subtotal: number;
|
||||
taxAmount: number;
|
||||
discountAmount: number;
|
||||
total: number;
|
||||
paymentMethod: 'cash' | 'card' | 'upi' | 'split' | 'store_credit';
|
||||
}
|
||||
|
||||
export interface PurchaseOrderItem {
|
||||
productId: string;
|
||||
name: string;
|
||||
qtyOrdered: number;
|
||||
qtyReceived: number;
|
||||
unitCost: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
id: string;
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
value: number;
|
||||
status: 'pending' | 'in_transit' | 'received';
|
||||
orderedAt: string;
|
||||
items: PurchaseOrderItem[];
|
||||
}
|
||||
|
||||
export interface Supplier {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
paymentTerms: string;
|
||||
}
|
||||
|
||||
export interface Promotion {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'percentage' | 'fixed' | 'buy_x_get_y' | 'scheduled';
|
||||
value: number;
|
||||
description: string;
|
||||
applyTo: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: 'active' | 'scheduled' | 'expired';
|
||||
usageCount: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface DailySale {
|
||||
day: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
time: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface TopProduct {
|
||||
rank: number;
|
||||
name: string;
|
||||
qty: number;
|
||||
revenue: number;
|
||||
trend: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
todayStats: {
|
||||
sales: number;
|
||||
transactions: number;
|
||||
avgBasket: number;
|
||||
lowStockCount: number;
|
||||
};
|
||||
weeklySales: DailySale[];
|
||||
topProducts: TopProduct[];
|
||||
recentActivity: ActivityItem[];
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
29
tailwind.config.js
Normal file
29
tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
shake: {
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-5px)' },
|
||||
'75%': { transform: 'translateX(5px)' }
|
||||
}
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#662582',
|
||||
light: '#f3e8f8',
|
||||
dark: '#4a1b5e'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Segoe UI', 'system-ui', '-apple-system', 'sans-serif']
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
29
tsconfig.app.json
Normal file
29
tsconfig.app.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
vite.config.ts
Normal file
32
vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'serve-favicon',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url === '/favicon.png') {
|
||||
const faviconPath = path.resolve(__dirname, '../favicon.png');
|
||||
if (fs.existsSync(faviconPath)) {
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
fs.createReadStream(faviconPath).pipe(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user