diff --git a/client/package.json b/client/package.json new file mode 100644 index 000000000..8c4fe7c65 --- /dev/null +++ b/client/package.json @@ -0,0 +1,70 @@ +{ + "name": "inventory-app-client", + "version": "1.0.0", + "private": true, + "dependencies": { + "@types/node": "^16.18.68", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "typescript": "^4.9.5", + "web-vitals": "^2.1.4", + "react-router-dom": "^6.20.1", + "axios": "^1.6.2", + "socket.io-client": "^4.7.4", + "recharts": "^2.8.0", + "lucide-react": "^0.294.0", + "clsx": "^2.0.0", + "tailwind-merge": "^2.0.0", + "date-fns": "^2.30.0", + "react-hook-form": "^7.48.2", + "react-hot-toast": "^2.4.1", + "framer-motion": "^10.16.16", + "react-query": "^3.39.3", + "zustand": "^4.4.7", + "react-table": "^7.8.0", + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "react-select": "^5.8.0", + "react-datepicker": "^4.25.0", + "@types/react-datepicker": "^4.19.4", + "react-dropzone": "^14.2.3", + "qrcode.react": "^3.1.0", + "html2canvas": "^1.4.1", + "jspdf": "^2.5.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react-table": "^7.7.15", + "@types/react-dropzone": "^5.1.0", + "tailwindcss": "^3.3.6", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32" + }, + "proxy": "http://localhost:5000" +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 000000000..08f25a563 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Toaster } from 'react-hot-toast'; +import { motion, AnimatePresence } from 'framer-motion'; + +// Components +import Layout from './components/Layout/Layout'; +import Dashboard from './pages/Dashboard/Dashboard'; +import Inventory from './pages/Inventory/Inventory'; +import Reports from './pages/Reports/Reports'; +import Sales from './pages/Sales/Sales'; +import Categories from './pages/Categories/Categories'; +import Suppliers from './pages/Suppliers/Suppliers'; +import Customers from './pages/Customers/Customers'; +import Settings from './pages/Settings/Settings'; + +// Styles +import './index.css'; + +// Create a client for React Query +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, // 5 minutes + }, + }, +}); + +function App() { + return ( + + +
+ {/* Animated background */} +
+ + {/* Main content */} + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + {/* Toast notifications */} + +
+
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/client/src/components/Layout/Layout.tsx b/client/src/components/Layout/Layout.tsx new file mode 100644 index 000000000..592cd2593 --- /dev/null +++ b/client/src/components/Layout/Layout.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + LayoutDashboard, + Package, + BarChart3, + ShoppingCart, + Tags, + Truck, + Users, + Settings, + Menu, + X, + Bell, + Search, + User +} from 'lucide-react'; + +interface LayoutProps { + children: React.ReactNode; +} + +const Layout: React.FC = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(false); + const location = useLocation(); + + const navigation = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Inventory', href: '/inventory', icon: Package }, + { name: 'Reports', href: '/reports', icon: BarChart3 }, + { name: 'Sales', href: '/sales', icon: ShoppingCart }, + { name: 'Categories', href: '/categories', icon: Tags }, + { name: 'Suppliers', href: '/suppliers', icon: Truck }, + { name: 'Customers', href: '/customers', icon: Users }, + { name: 'Settings', href: '/settings', icon: Settings }, + ]; + + const isActive = (href: string) => { + if (href === '/') { + return location.pathname === '/'; + } + return location.pathname.startsWith(href); + }; + + return ( +
+ {/* Mobile sidebar overlay */} + + {sidebarOpen && ( + setSidebarOpen(false)} + > +
+ + )} + + + {/* Sidebar */} + +
+ {/* Logo */} +
+ +
+ +
+ + INVENTORY + + + +
+ + {/* Navigation */} + + + {/* User info */} +
+
+
+ +
+
+

+ Admin User +

+

+ admin@inventory.com +

+
+
+
+
+
+ + {/* Main content */} +
+ {/* Header */} +
+
+
+ + + {/* Search bar */} +
+ + +
+
+ +
+ {/* Notifications */} + + + {/* Quick actions */} + +
+
+
+ + {/* Page content */} +
+ + + {children} + + +
+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 000000000..b2e40b68c --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,270 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; + +/* Google Fonts */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&display=swap'); + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1e293b; +} + +::-webkit-scrollbar-thumb { + background: #3b82f6; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #2563eb; +} + +/* Custom selection */ +::selection { + background: rgba(59, 130, 246, 0.3); + color: #fff; +} + +/* Custom focus styles */ +.focus-visible:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +/* Neon text effect */ +.text-neon { + text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor; +} + +/* Glass morphism effect */ +.glass { + background: rgba(30, 41, 59, 0.8); + backdrop-filter: blur(16px); + border: 1px solid rgba(59, 130, 246, 0.2); +} + +/* Cyber button styles */ +.btn-cyber { + position: relative; + background: linear-gradient(45deg, #1e293b, #334155); + border: 1px solid #3b82f6; + color: #3b82f6; + padding: 12px 24px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + overflow: hidden; + transition: all 0.3s ease; + cursor: pointer; +} + +.btn-cyber::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.2), transparent); + transition: left 0.5s; +} + +.btn-cyber:hover::before { + left: 100%; +} + +.btn-cyber:hover { + background: linear-gradient(45deg, #334155, #475569); + color: #60a5fa; + box-shadow: 0 0 20px rgba(59, 130, 246, 0.5); + transform: translateY(-2px); +} + +.btn-cyber:active { + transform: translateY(0); +} + +/* Input field styles */ +.input-cyber { + background: rgba(30, 41, 59, 0.8); + border: 1px solid #475569; + color: #fff; + padding: 12px 16px; + border-radius: 8px; + transition: all 0.3s ease; +} + +.input-cyber:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + outline: none; +} + +.input-cyber::placeholder { + color: #64748b; +} + +/* Card styles */ +.card-cyber { + background: rgba(30, 41, 59, 0.9); + border: 1px solid #475569; + border-radius: 12px; + backdrop-filter: blur(16px); + transition: all 0.3s ease; +} + +.card-cyber:hover { + border-color: #3b82f6; + box-shadow: 0 0 20px rgba(59, 130, 246, 0.2); + transform: translateY(-2px); +} + +/* Table styles */ +.table-cyber { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.table-cyber th { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + font-weight: 600; + text-align: left; + padding: 16px; + border-bottom: 1px solid #475569; +} + +.table-cyber td { + padding: 16px; + border-bottom: 1px solid #334155; + transition: background-color 0.2s ease; +} + +.table-cyber tbody tr:hover { + background: rgba(59, 130, 246, 0.05); +} + +/* Status indicators */ +.status-low { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + border: 1px solid #ef4444; +} + +.status-normal { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + border: 1px solid #22c55e; +} + +.status-overstocked { + background: rgba(245, 158, 11, 0.2); + color: #fcd34d; + border: 1px solid #f59e0b; +} + +/* Loading animation */ +.loading-shimmer { + background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; +} + +/* Pulse animation for alerts */ +.pulse-alert { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Floating animation for cards */ +.float-card { + animation: float 6s ease-in-out infinite; +} + +/* Glow effect for active elements */ +.glow-active { + box-shadow: 0 0 20px rgba(59, 130, 246, 0.5); +} + +/* Responsive grid */ +.grid-responsive { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; +} + +/* Custom animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Animation classes */ +.animate-fade-in-up { + animation: fadeInUp 0.6s ease-out; +} + +.animate-slide-in-left { + animation: slideInLeft 0.6s ease-out; +} + +.animate-slide-in-right { + animation: slideInRight 0.6s ease-out; +} + +/* Responsive utilities */ +@media (max-width: 768px) { + .grid-responsive { + grid-template-columns: 1fr; + } + + .card-cyber { + margin: 12px; + } +} + +/* Print styles */ +@media print { + .no-print { + display: none !important; + } + + .print-break { + page-break-before: always; + } +} \ No newline at end of file diff --git a/client/tailwind.config.js b/client/tailwind.config.js new file mode 100644 index 000000000..d41e87582 --- /dev/null +++ b/client/tailwind.config.js @@ -0,0 +1,167 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + }, + secondary: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + }, + accent: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + dark: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + warning: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + }, + danger: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + }, + neon: { + blue: '#00f5ff', + purple: '#bf00ff', + green: '#00ff41', + pink: '#ff0080', + yellow: '#ffff00', + orange: '#ff6600', + } + }, + fontFamily: { + 'sans': ['Inter', 'system-ui', 'sans-serif'], + 'mono': ['JetBrains Mono', 'monospace'], + 'display': ['Orbitron', 'monospace'], + }, + animation: { + 'fade-in': 'fadeIn 0.5s ease-in-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'slide-down': 'slideDown 0.3s ease-out', + 'scale-in': 'scaleIn 0.2s ease-out', + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'glow': 'glow 2s ease-in-out infinite alternate', + 'float': 'float 6s ease-in-out infinite', + 'shimmer': 'shimmer 2s linear infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + slideDown: { + '0%': { transform: 'translateY(-10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + scaleIn: { + '0%': { transform: 'scale(0.95)', opacity: '0' }, + '100%': { transform: 'scale(1)', opacity: '1' }, + }, + glow: { + '0%': { boxShadow: '0 0 5px #00f5ff, 0 0 10px #00f5ff, 0 0 15px #00f5ff' }, + '100%': { boxShadow: '0 0 10px #00f5ff, 0 0 20px #00f5ff, 0 0 30px #00f5ff' }, + }, + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-10px)' }, + }, + shimmer: { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(100%)' }, + }, + }, + backdropBlur: { + xs: '2px', + }, + boxShadow: { + 'neon': '0 0 5px #00f5ff, 0 0 10px #00f5ff, 0 0 15px #00f5ff', + 'neon-purple': '0 0 5px #bf00ff, 0 0 10px #bf00ff, 0 0 15px #bf00ff', + 'neon-green': '0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41', + 'inner-neon': 'inset 0 0 5px #00f5ff, inset 0 0 10px #00f5ff', + }, + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + 'cyber-grid': 'linear-gradient(rgba(0, 245, 255, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 245, 255, 0.1) 1px, transparent 1px)', + }, + backgroundSize: { + 'cyber-grid': '20px 20px', + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 000000000..231fcb456 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "inventory-app", + "version": "1.0.0", + "description": "Modern inventory management application for reselling business", + "main": "dist/server/index.js", + "scripts": { + "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", + "dev:server": "nodemon --exec ts-node src/server/index.ts", + "dev:client": "cd client && npm start", + "build": "npm run build:server && npm run build:client", + "build:server": "tsc -p tsconfig.server.json", + "build:client": "cd client && npm run build", + "start": "node dist/server/index.js", + "install:all": "npm install && cd client && npm install", + "db:setup": "npm run db:create && npm run db:migrate", + "db:create": "createdb inventory_app", + "db:migrate": "psql -d inventory_app -f src/database/schema.sql" + }, + "keywords": ["inventory", "management", "reselling", "business"], + "author": "Your Name", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "pg": "^8.11.3", + "dotenv": "^16.3.1", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "express-validator": "^7.0.1", + "multer": "^1.4.5-lts.1", + "qrcode": "^1.5.3", + "barcode": "^0.1.4" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/morgan": "^1.9.9", + "@types/pg": "^8.10.7", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.5", + "@types/multer": "^1.4.11", + "@types/node": "^20.8.10", + "typescript": "^5.2.2", + "ts-node": "^10.9.1", + "nodemon": "^3.0.1", + "concurrently": "^8.2.2" + } +} \ No newline at end of file diff --git a/src/database/schema.sql b/src/database/schema.sql new file mode 100644 index 000000000..b7d4c27b6 --- /dev/null +++ b/src/database/schema.sql @@ -0,0 +1,252 @@ +-- Inventory Management Database Schema +-- PostgreSQL database for reselling business inventory + +-- Create database if it doesn't exist +-- CREATE DATABASE inventory_app; + +-- Connect to the database +-- \c inventory_app; + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Categories table for organizing items +CREATE TABLE categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + color VARCHAR(7) DEFAULT '#3B82F6', + icon VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Items table for inventory tracking +CREATE TABLE items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + description TEXT, + upc VARCHAR(50) UNIQUE, + sku VARCHAR(100) UNIQUE, + category_id UUID REFERENCES categories(id) ON DELETE SET NULL, + cost DECIMAL(10,2) NOT NULL DEFAULT 0.00, + sale_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + profit_margin DECIMAL(5,2) GENERATED ALWAYS AS ( + CASE + WHEN cost > 0 THEN ((sale_price - cost) / cost * 100) + ELSE 0 + END + ) STORED, + profit_amount DECIMAL(10,2) GENERATED ALWAYS AS (sale_price - cost) STORED, + quantity_in_stock INTEGER NOT NULL DEFAULT 0, + min_stock_level INTEGER DEFAULT 0, + max_stock_level INTEGER, + location VARCHAR(100), + condition_notes TEXT, + tags TEXT[], + image_url VARCHAR(500), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Transactions table for tracking sales and purchases +CREATE TABLE transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + transaction_type VARCHAR(20) NOT NULL CHECK (transaction_type IN ('sale', 'purchase', 'adjustment', 'return')), + quantity INTEGER NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + total_amount DECIMAL(10,2) NOT NULL, + transaction_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + notes TEXT, + reference_number VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Suppliers table +CREATE TABLE suppliers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + contact_person VARCHAR(100), + email VARCHAR(255), + phone VARCHAR(20), + address TEXT, + website VARCHAR(255), + notes TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Purchase orders table +CREATE TABLE purchase_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + supplier_id UUID REFERENCES suppliers(id) ON DELETE SET NULL, + order_number VARCHAR(100) UNIQUE NOT NULL, + order_date DATE NOT NULL, + expected_delivery DATE, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'ordered', 'received', 'cancelled')), + total_amount DECIMAL(10,2) DEFAULT 0.00, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Purchase order items +CREATE TABLE purchase_order_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + purchase_order_id UUID REFERENCES purchase_orders(id) ON DELETE CASCADE, + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL, + unit_cost DECIMAL(10,2) NOT NULL, + total_cost DECIMAL(10,2) NOT NULL, + received_quantity INTEGER DEFAULT 0, + notes TEXT +); + +-- Customers table +CREATE TABLE customers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) UNIQUE, + phone VARCHAR(20), + address TEXT, + notes TEXT, + total_purchases DECIMAL(10,2) DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Sales table +CREATE TABLE sales ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, + sale_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + total_amount DECIMAL(10,2) NOT NULL, + tax_amount DECIMAL(10,2) DEFAULT 0.00, + discount_amount DECIMAL(10,2) DEFAULT 0.00, + payment_method VARCHAR(50), + status VARCHAR(20) DEFAULT 'completed' CHECK (status IN ('pending', 'completed', 'cancelled', 'refunded')), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Sale items +CREATE TABLE sale_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + sale_id UUID REFERENCES sales(id) ON DELETE CASCADE, + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + quantity INTEGER NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + total_price DECIMAL(10,2) NOT NULL, + cost_at_sale DECIMAL(10,2) NOT NULL, + profit_amount DECIMAL(10,2) GENERATED ALWAYS AS (total_price - (cost_at_sale * quantity)) STORED +); + +-- Create indexes for better performance +CREATE INDEX idx_items_upc ON items(upc); +CREATE INDEX idx_items_sku ON items(sku); +CREATE INDEX idx_items_category ON items(category_id); +CREATE INDEX idx_items_stock ON items(quantity_in_stock); +CREATE INDEX idx_transactions_item ON transactions(item_id); +CREATE INDEX idx_transactions_date ON transactions(transaction_date); +CREATE INDEX idx_transactions_type ON transactions(transaction_type); +CREATE INDEX idx_sales_date ON sales(sale_date); +CREATE INDEX idx_sales_customer ON sales(customer_id); + +-- Create updated_at trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create triggers for updated_at +CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON categories + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_items_updated_at BEFORE UPDATE ON items + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_suppliers_updated_at BEFORE UPDATE ON suppliers + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_purchase_orders_updated_at BEFORE UPDATE ON purchase_orders + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_customers_updated_at BEFORE UPDATE ON customers + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Insert default categories +INSERT INTO categories (name, description, color, icon) VALUES +('Electronics', 'Electronic devices and accessories', '#3B82F6', 'laptop'), +('Collectibles', 'Collectible items and memorabilia', '#EF4444', 'star'), +('Clothing', 'Apparel and accessories', '#10B981', 'tshirt'), +('Home & Garden', 'Home improvement and garden items', '#F59E0B', 'home'), +('Sports', 'Sports equipment and memorabilia', '#8B5CF6', 'trophy'), +('Books', 'Books and publications', '#06B6D4', 'book'), +('Toys & Games', 'Toys, games, and entertainment', '#EC4899', 'gamepad'), +('Other', 'Miscellaneous items', '#6B7280', 'box'); + +-- Create views for common reports +CREATE VIEW inventory_summary AS +SELECT + i.id, + i.name, + i.upc, + i.sku, + c.name as category_name, + i.cost, + i.sale_price, + i.profit_margin, + i.profit_amount, + i.quantity_in_stock, + i.min_stock_level, + i.max_stock_level, + i.location, + CASE + WHEN i.quantity_in_stock <= i.min_stock_level THEN 'Low Stock' + WHEN i.quantity_in_stock >= COALESCE(i.max_stock_level, 999999) THEN 'Overstocked' + ELSE 'Normal' + END as stock_status +FROM items i +LEFT JOIN categories c ON i.category_id = c.id +WHERE i.is_active = true; + +CREATE VIEW top_selling_items AS +SELECT + i.id, + i.name, + i.upc, + c.name as category_name, + COUNT(si.id) as total_sales, + SUM(si.quantity) as total_quantity_sold, + SUM(si.total_price) as total_revenue, + AVG(si.profit_amount) as avg_profit_per_item +FROM items i +LEFT JOIN categories c ON i.category_id = c.id +LEFT JOIN sale_items si ON i.id = si.item_id +LEFT JOIN sales s ON si.sale_id = s.id +WHERE s.status = 'completed' +GROUP BY i.id, i.name, i.upc, c.name +ORDER BY total_revenue DESC; + +CREATE VIEW profit_analysis AS +SELECT + DATE_TRUNC('month', s.sale_date) as month, + c.name as category_name, + COUNT(DISTINCT s.id) as total_sales, + SUM(si.total_price) as total_revenue, + SUM(si.total_price - (si.cost_at_sale * si.quantity)) as total_profit, + AVG(si.total_price - (si.cost_at_sale * si.quantity)) as avg_profit_per_sale +FROM sales s +JOIN sale_items si ON s.id = si.sale_id +JOIN items i ON si.item_id = i.id +LEFT JOIN categories c ON i.category_id = c.id +WHERE s.status = 'completed' +GROUP BY DATE_TRUNC('month', s.sale_date), c.name +ORDER BY month DESC, total_profit DESC; \ No newline at end of file diff --git a/src/server/config/database.ts b/src/server/config/database.ts new file mode 100644 index 000000000..e03e1c636 --- /dev/null +++ b/src/server/config/database.ts @@ -0,0 +1,129 @@ +import { Pool, PoolClient } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Database configuration +const dbConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'inventory_app', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, + max: 20, // Maximum number of clients in the pool + idleTimeoutMillis: 30000, // Close idle clients after 30 seconds + connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established +}; + +// Create connection pool +const pool = new Pool(dbConfig); + +// Test database connection +export const testConnection = async (): Promise => { + try { + const client = await pool.connect(); + await client.query('SELECT NOW()'); + client.release(); + return true; + } catch (error) { + console.error('Database connection test failed:', error); + return false; + } +}; + +// Get a client from the pool +export const getClient = async (): Promise => { + return await pool.connect(); +}; + +// Execute a query with a client from the pool +export const query = async (text: string, params?: any[]): Promise => { + const start = Date.now(); + try { + const res = await pool.query(text, params); + const duration = Date.now() - start; + console.log('Executed query', { text, duration, rows: res.rowCount }); + return res; + } catch (error) { + console.error('Query execution failed:', { text, error }); + throw error; + } +}; + +// Execute a transaction +export const executeTransaction = async (queries: Array<{ text: string; params?: any[] }>): Promise => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const results = []; + + for (const queryObj of queries) { + const result = await client.query(queryObj.text, queryObj.params); + results.push(result); + } + + await client.query('COMMIT'); + return results; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +// Connect to database +export const connectDB = async (): Promise => { + try { + // Test connection + const isConnected = await testConnection(); + if (!isConnected) { + throw new Error('Database connection test failed'); + } + + console.log('✅ Database connection established successfully'); + + // Set up event handlers for the pool + pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); + }); + + pool.on('connect', (client) => { + console.log('🔌 New client connected to database'); + }); + + pool.on('remove', (client) => { + console.log('🔌 Client removed from database pool'); + }); + + } catch (error) { + console.error('❌ Database connection failed:', error); + throw error; + } +}; + +// Close database connection +export const closeDB = async (): Promise => { + try { + await pool.end(); + console.log('✅ Database connection closed successfully'); + } catch (error) { + console.error('❌ Error closing database connection:', error); + throw error; + } +}; + +// Export pool for direct access if needed +export { pool }; + +export default { + connectDB, + closeDB, + query, + executeTransaction, + getClient, + testConnection, + pool +}; \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 000000000..7bac10d0d --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,164 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import dotenv from 'dotenv'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; +import path from 'path'; + +// Import routes +import inventoryRoutes from './routes/inventory'; +import categoryRoutes from './routes/categories'; +import transactionRoutes from './routes/transactions'; +import supplierRoutes from './routes/suppliers'; +import customerRoutes from './routes/customers'; +import salesRoutes from './routes/sales'; +import reportRoutes from './routes/reports'; +import authRoutes from './routes/auth'; + +// Import database connection +import { connectDB } from './config/database'; + +// Load environment variables +dotenv.config(); + +const app = express(); +const server = createServer(app); +const io = new Server(server, { + cors: { + origin: process.env.CLIENT_URL || "http://localhost:3000", + methods: ["GET", "POST", "PUT", "DELETE"] + } +}); + +const PORT = process.env.PORT || 5000; + +// Middleware +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + imgSrc: ["'self'", "data:", "https:"], + scriptSrc: ["'self'", "'unsafe-eval'"], + }, + }, +})); +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:3000", + credentials: true +})); +app.use(morgan('combined')); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Static files +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); + +// API Routes +app.use('/api/auth', authRoutes); +app.use('/api/inventory', inventoryRoutes); +app.use('/api/categories', categoryRoutes); +app.use('/api/transactions', transactionRoutes); +app.use('/api/suppliers', supplierRoutes); +app.use('/api/customers', customerRoutes); +app.use('/api/sales', salesRoutes); +app.use('/api/reports', reportRoutes); + +// Health check endpoint +app.get('/api/health', (req, res) => { + res.json({ + status: 'OK', + timestamp: new Date().toISOString(), + uptime: process.uptime() + }); +}); + +// Serve React app in production +if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, '../../client/build'))); + + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../../client/build/index.html')); + }); +} + +// Error handling middleware +app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error(err.stack); + res.status(500).json({ + error: 'Something went wrong!', + message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error' + }); +}); + +// 404 handler +app.use('*', (req, res) => { + res.status(404).json({ error: 'Route not found' }); +}); + +// Socket.IO connection handling +io.on('connection', (socket) => { + console.log('Client connected:', socket.id); + + socket.on('disconnect', () => { + console.log('Client disconnected:', socket.id); + }); + + // Join inventory room for real-time updates + socket.on('join-inventory', () => { + socket.join('inventory'); + }); + + socket.on('leave-inventory', () => { + socket.leave('inventory'); + }); +}); + +// Make io available to routes +app.set('io', io); + +// Start server +const startServer = async () => { + try { + // Connect to database + await connectDB(); + console.log('✅ Database connected successfully'); + + // Start server + server.listen(PORT, () => { + console.log(`🚀 Server running on port ${PORT}`); + console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`); + console.log(`🌐 API URL: http://localhost:${PORT}/api`); + if (process.env.NODE_ENV !== 'production') { + console.log(`🔧 Client URL: http://localhost:3000`); + } + }); + } catch (error) { + console.error('❌ Failed to start server:', error); + process.exit(1); + } +}; + +startServer(); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully'); + server.close(() => { + console.log('Process terminated'); + process.exit(0); + }); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully'); + server.close(() => { + console.log('Process terminated'); + process.exit(0); + }); +}); + +export default app; \ No newline at end of file diff --git a/src/server/routes/inventory.ts b/src/server/routes/inventory.ts new file mode 100644 index 000000000..06f3e8e73 --- /dev/null +++ b/src/server/routes/inventory.ts @@ -0,0 +1,519 @@ +import express from 'express'; +import { body, param, query, validationResult } from 'express-validator'; +import { query as dbQuery, executeTransaction } from '../config/database'; +import { Request, Response } from 'express'; + +const router = express.Router(); + +// Get all inventory items with pagination and filtering +router.get('/', [ + query('page').optional().isInt({ min: 1 }).withMessage('Page must be a positive integer'), + query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100'), + query('search').optional().isString().withMessage('Search must be a string'), + query('category').optional().isUUID().withMessage('Category must be a valid UUID'), + query('stockStatus').optional().isIn(['low', 'normal', 'overstocked']).withMessage('Invalid stock status'), + query('sortBy').optional().isIn(['name', 'cost', 'sale_price', 'profit_margin', 'quantity_in_stock', 'created_at']).withMessage('Invalid sort field'), + query('sortOrder').optional().isIn(['asc', 'desc']).withMessage('Sort order must be asc or desc') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + page = 1, + limit = 20, + search = '', + category = '', + stockStatus = '', + sortBy = 'created_at', + sortOrder = 'desc' + } = req.query; + + const offset = (Number(page) - 1) * Number(limit); + + // Build WHERE clause + let whereClause = 'WHERE i.is_active = true'; + const params: any[] = []; + let paramIndex = 1; + + if (search) { + whereClause += ` AND (i.name ILIKE $${paramIndex} OR i.description ILIKE $${paramIndex} OR i.upc ILIKE $${paramIndex} OR i.sku ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + if (category) { + whereClause += ` AND i.category_id = $${paramIndex}`; + params.push(category); + paramIndex++; + } + + if (stockStatus) { + switch (stockStatus) { + case 'low': + whereClause += ` AND i.quantity_in_stock <= i.min_stock_level`; + break; + case 'overstocked': + whereClause += ` AND i.quantity_in_stock >= COALESCE(i.max_stock_level, 999999)`; + break; + case 'normal': + whereClause += ` AND i.quantity_in_stock > i.min_stock_level AND (i.max_stock_level IS NULL OR i.quantity_in_stock < i.max_stock_level)`; + break; + } + } + + // Build ORDER BY clause + const orderByClause = `ORDER BY i.${sortBy} ${sortOrder.toUpperCase()}`; + + // Get total count + const countQuery = ` + SELECT COUNT(*) as total + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + ${whereClause} + `; + + const countResult = await dbQuery(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // Get items + const itemsQuery = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color, + c.icon as category_icon + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + ${whereClause} + ${orderByClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + params.push(Number(limit), offset); + const itemsResult = await dbQuery(itemsQuery, params); + + // Calculate pagination info + const totalPages = Math.ceil(total / Number(limit)); + const hasNextPage = Number(page) < totalPages; + const hasPrevPage = Number(page) > 1; + + res.json({ + items: itemsResult.rows, + pagination: { + page: Number(page), + limit: Number(limit), + total, + totalPages, + hasNextPage, + hasPrevPage + } + }); + + } catch (error) { + console.error('Error fetching inventory:', error); + res.status(500).json({ error: 'Failed to fetch inventory' }); + } +}); + +// Get single inventory item by ID +router.get('/:id', [ + param('id').isUUID().withMessage('Invalid item ID') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + + const query = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color, + c.icon as category_icon + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + WHERE i.id = $1 AND i.is_active = true + `; + + const result = await dbQuery(query, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Item not found' }); + } + + res.json(result.rows[0]); + + } catch (error) { + console.error('Error fetching item:', error); + res.status(500).json({ error: 'Failed to fetch item' }); + } +}); + +// Create new inventory item +router.post('/', [ + body('name').trim().isLength({ min: 1, max: 255 }).withMessage('Name is required and must be less than 255 characters'), + body('description').optional().isString(), + body('upc').optional().isString().isLength({ max: 50 }), + body('sku').optional().isString().isLength({ max: 100 }), + body('category_id').optional().isUUID().withMessage('Invalid category ID'), + body('cost').isFloat({ min: 0 }).withMessage('Cost must be a positive number'), + body('sale_price').isFloat({ min: 0 }).withMessage('Sale price must be a positive number'), + body('quantity_in_stock').isInt({ min: 0 }).withMessage('Quantity must be a non-negative integer'), + body('min_stock_level').optional().isInt({ min: 0 }).withMessage('Min stock level must be a non-negative integer'), + body('max_stock_level').optional().isInt({ min: 1 }).withMessage('Max stock level must be a positive integer'), + body('location').optional().isString().isLength({ max: 100 }), + body('condition_notes').optional().isString(), + body('tags').optional().isArray(), + body('image_url').optional().isURL().withMessage('Invalid image URL') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + name, + description, + upc, + sku, + category_id, + cost, + sale_price, + quantity_in_stock, + min_stock_level, + max_stock_level, + location, + condition_notes, + tags, + image_url + } = req.body; + + // Check if UPC or SKU already exists + if (upc || sku) { + const existingQuery = ` + SELECT id FROM items + WHERE (upc = $1 OR sku = $2) AND is_active = true + `; + const existingResult = await dbQuery(existingQuery, [upc || null, sku || null]); + + if (existingResult.rows.length > 0) { + return res.status(400).json({ error: 'Item with this UPC or SKU already exists' }); + } + } + + const insertQuery = ` + INSERT INTO items ( + name, description, upc, sku, category_id, cost, sale_price, + quantity_in_stock, min_stock_level, max_stock_level, location, + condition_notes, tags, image_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING * + `; + + const result = await dbQuery(insertQuery, [ + name, description, upc, sku, category_id, cost, sale_price, + quantity_in_stock, min_stock_level, max_stock_level, location, + condition_notes, tags, image_url + ]); + + // Emit real-time update + const io = req.app.get('io'); + if (io) { + io.to('inventory').emit('inventory-updated', { + type: 'item-created', + item: result.rows[0] + }); + } + + res.status(201).json(result.rows[0]); + + } catch (error) { + console.error('Error creating item:', error); + res.status(500).json({ error: 'Failed to create item' }); + } +}); + +// Update inventory item +router.put('/:id', [ + param('id').isUUID().withMessage('Invalid item ID'), + body('name').optional().trim().isLength({ min: 1, max: 255 }), + body('description').optional().isString(), + body('upc').optional().isString().isLength({ max: 50 }), + body('sku').optional().isString().isLength({ max: 100 }), + body('category_id').optional().isUUID().withMessage('Invalid category ID'), + body('cost').optional().isFloat({ min: 0 }), + body('sale_price').optional().isFloat({ min: 0 }), + body('quantity_in_stock').optional().isInt({ min: 0 }), + body('min_stock_level').optional().isInt({ min: 0 }), + body('max_stock_level').optional().isInt({ min: 1 }), + body('location').optional().isString().isLength({ max: 100 }), + body('condition_notes').optional().isString(), + body('tags').optional().isArray(), + body('image_url').optional().isURL() +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const updateData = req.body; + + // Check if item exists + const existingQuery = 'SELECT * FROM items WHERE id = $1 AND is_active = true'; + const existingResult = await dbQuery(existingQuery, [id]); + + if (existingResult.rows.length === 0) { + return res.status(404).json({ error: 'Item not found' }); + } + + // Check if UPC or SKU already exists (excluding current item) + if (updateData.upc || updateData.sku) { + const duplicateQuery = ` + SELECT id FROM items + WHERE (upc = $1 OR sku = $2) AND id != $3 AND is_active = true + `; + const duplicateResult = await dbQuery(duplicateQuery, [ + updateData.upc || null, + updateData.sku || null, + id + ]); + + if (duplicateResult.rows.length > 0) { + return res.status(400).json({ error: 'Item with this UPC or SKU already exists' }); + } + } + + // Build dynamic update query + const setClause: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + Object.keys(updateData).forEach(key => { + if (updateData[key] !== undefined) { + setClause.push(`${key} = $${paramIndex}`); + values.push(updateData[key]); + paramIndex++; + } + }); + + if (setClause.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + setClause.push(`updated_at = $${paramIndex}`); + values.push(new Date()); + paramIndex++; + + values.push(id); + + const updateQuery = ` + UPDATE items + SET ${setClause.join(', ')} + WHERE id = $${paramIndex} + RETURNING * + `; + + const result = await dbQuery(updateQuery, values); + + // Emit real-time update + const io = req.app.get('io'); + if (io) { + io.to('inventory').emit('inventory-updated', { + type: 'item-updated', + item: result.rows[0] + }); + } + + res.json(result.rows[0]); + + } catch (error) { + console.error('Error updating item:', error); + res.status(500).json({ error: 'Failed to update item' }); + } +}); + +// Delete inventory item (soft delete) +router.delete('/:id', [ + param('id').isUUID().withMessage('Invalid item ID') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + + const deleteQuery = ` + UPDATE items + SET is_active = false, updated_at = $1 + WHERE id = $2 AND is_active = true + RETURNING id + `; + + const result = await dbQuery(deleteQuery, [new Date(), id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Item not found' }); + } + + // Emit real-time update + const io = req.app.get('io'); + if (io) { + io.to('inventory').emit('inventory-updated', { + type: 'item-deleted', + itemId: id + }); + } + + res.json({ message: 'Item deleted successfully' }); + + } catch (error) { + console.error('Error deleting item:', error); + res.status(500).json({ error: 'Failed to delete item' }); + } +}); + +// Bulk update inventory quantities +router.post('/bulk-update', [ + body('updates').isArray({ min: 1 }).withMessage('Updates must be a non-empty array'), + body('updates.*.id').isUUID().withMessage('Each update must have a valid item ID'), + body('updates.*.quantity_change').isInt().withMessage('Each update must have a valid quantity change'), + body('updates.*.notes').optional().isString() +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { updates } = req.body; + + // Execute bulk update in transaction + const queries = updates.map((update: any) => ({ + text: ` + UPDATE items + SET quantity_in_stock = quantity_in_stock + $1, updated_at = $2 + WHERE id = $3 AND is_active = true + `, + params: [update.quantity_change, new Date(), update.id] + })); + + const results = await executeTransaction(queries); + + // Emit real-time update + const io = req.app.get('io'); + if (io) { + io.to('inventory').emit('inventory-updated', { + type: 'bulk-update', + updates: updates.map((update: any, index: number) => ({ + ...update, + newQuantity: results[index].rows[0]?.quantity_in_stock + })) + }); + } + + res.json({ message: 'Bulk update completed successfully', results }); + + } catch (error) { + console.error('Error in bulk update:', error); + res.status(500).json({ error: 'Failed to perform bulk update' }); + } +}); + +// Search items by UPC +router.get('/search/upc/:upc', [ + param('upc').isString().isLength({ min: 1, max: 50 }) +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { upc } = req.params; + + const query = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color, + c.icon as category_icon + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + WHERE i.upc = $1 AND i.is_active = true + `; + + const result = await dbQuery(query, [upc]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Item not found' }); + } + + res.json(result.rows[0]); + + } catch (error) { + console.error('Error searching by UPC:', error); + res.status(500).json({ error: 'Failed to search by UPC' }); + } +}); + +// Get low stock items +router.get('/alerts/low-stock', async (req: Request, res: Response) => { + try { + const query = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + WHERE i.quantity_in_stock <= i.min_stock_level + AND i.is_active = true + ORDER BY i.quantity_in_stock ASC + `; + + const result = await dbQuery(query); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching low stock items:', error); + res.status(500).json({ error: 'Failed to fetch low stock items' }); + } +}); + +// Get overstocked items +router.get('/alerts/overstocked', async (req: Request, res: Response) => { + try { + const query = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + WHERE i.max_stock_level IS NOT NULL + AND i.quantity_in_stock >= i.max_stock_level + AND i.is_active = true + ORDER BY i.quantity_in_stock DESC + `; + + const result = await dbQuery(query); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching overstocked items:', error); + res.status(500).json({ error: 'Failed to fetch overstocked items' }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/server/routes/reports.ts b/src/server/routes/reports.ts new file mode 100644 index 000000000..65c996ac7 --- /dev/null +++ b/src/server/routes/reports.ts @@ -0,0 +1,575 @@ +import express from 'express'; +import { query, param, body, validationResult } from 'express-validator'; +import { query as dbQuery } from '../config/database'; +import { Request, Response } from 'express'; + +const router = express.Router(); + +// Get dashboard summary statistics +router.get('/dashboard', async (req: Request, res: Response) => { + try { + // Get total inventory value + const inventoryValueQuery = ` + SELECT + COUNT(*) as total_items, + SUM(quantity_in_stock) as total_quantity, + SUM(cost * quantity_in_stock) as total_cost_value, + SUM(sale_price * quantity_in_stock) as total_sale_value, + SUM((sale_price - cost) * quantity_in_stock) as total_potential_profit + FROM items + WHERE is_active = true + `; + + // Get low stock alerts + const lowStockQuery = ` + SELECT COUNT(*) as low_stock_count + FROM items + WHERE quantity_in_stock <= min_stock_level AND is_active = true + `; + + // Get recent sales + const recentSalesQuery = ` + SELECT + COUNT(*) as total_sales, + SUM(total_amount) as total_revenue, + SUM(total_amount - COALESCE(tax_amount, 0) - COALESCE(discount_amount, 0)) as net_revenue + FROM sales + WHERE sale_date >= CURRENT_DATE - INTERVAL '30 days' + AND status = 'completed' + `; + + // Get top selling categories + const topCategoriesQuery = ` + SELECT + c.name as category_name, + c.color as category_color, + COUNT(si.id) as sales_count, + SUM(si.total_price) as total_revenue + FROM categories c + LEFT JOIN items i ON c.id = i.category_id + LEFT JOIN sale_items si ON i.id = si.item_id + LEFT JOIN sales s ON si.sale_id = s.id + WHERE s.status = 'completed' + AND s.sale_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY c.id, c.name, c.color + ORDER BY total_revenue DESC + LIMIT 5 + `; + + // Execute all queries + const [inventoryValue, lowStock, recentSales, topCategories] = await Promise.all([ + dbQuery(inventoryValueQuery), + dbQuery(lowStockQuery), + dbQuery(recentSalesQuery), + dbQuery(topCategoriesQuery) + ]); + + const summary = { + inventory: inventoryValue.rows[0], + alerts: { + lowStock: parseInt(lowStock.rows[0].low_stock_count) + }, + sales: recentSales.rows[0], + topCategories: topCategories.rows + }; + + res.json(summary); + + } catch (error) { + console.error('Error fetching dashboard data:', error); + res.status(500).json({ error: 'Failed to fetch dashboard data' }); + } +}); + +// Get inventory summary report +router.get('/inventory-summary', [ + query('category').optional().isUUID().withMessage('Invalid category ID'), + query('stockStatus').optional().isIn(['low', 'normal', 'overstocked']).withMessage('Invalid stock status'), + query('sortBy').optional().isIn(['name', 'cost', 'sale_price', 'profit_margin', 'quantity_in_stock']).withMessage('Invalid sort field'), + query('sortOrder').optional().isIn(['asc', 'desc']).withMessage('Sort order must be asc or desc') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { category, stockStatus, sortBy = 'name', sortOrder = 'asc' } = req.query; + + let whereClause = 'WHERE i.is_active = true'; + const params: any[] = []; + let paramIndex = 1; + + if (category) { + whereClause += ` AND i.category_id = $${paramIndex}`; + params.push(category); + paramIndex++; + } + + if (stockStatus) { + switch (stockStatus) { + case 'low': + whereClause += ` AND i.quantity_in_stock <= i.min_stock_level`; + break; + case 'overstocked': + whereClause += ` AND i.max_stock_level IS NOT NULL AND i.quantity_in_stock >= i.max_stock_level`; + break; + case 'normal': + whereClause += ` AND i.quantity_in_stock > i.min_stock_level AND (i.max_stock_level IS NULL OR i.quantity_in_stock < i.max_stock_level)`; + break; + } + } + + const query = ` + SELECT * FROM inventory_summary + ${whereClause} + ORDER BY ${sortBy} ${sortOrder.toUpperCase()} + `; + + const result = await dbQuery(query, params); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching inventory summary:', error); + res.status(500).json({ error: 'Failed to fetch inventory summary' }); + } +}); + +// Get top selling items report +router.get('/top-selling', [ + query('period').optional().isIn(['7d', '30d', '90d', '1y', 'all']).withMessage('Invalid period'), + query('category').optional().isUUID().withMessage('Invalid category ID'), + query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { period = '30d', category, limit = 20 } = req.query; + + let dateFilter = ''; + const params: any[] = []; + let paramIndex = 1; + + if (period !== 'all') { + const days = { + '7d': 7, + '30d': 30, + '90d': 90, + '1y': 365 + }[period as string]; + + dateFilter = `AND s.sale_date >= CURRENT_DATE - INTERVAL '${days} days'`; + } + + if (category) { + dateFilter += ` AND i.category_id = $${paramIndex}`; + params.push(category); + paramIndex++; + } + + const query = ` + SELECT + i.id, + i.name, + i.upc, + c.name as category_name, + c.color as category_color, + COUNT(si.id) as total_sales, + SUM(si.quantity) as total_quantity_sold, + SUM(si.total_price) as total_revenue, + AVG(si.profit_amount) as avg_profit_per_item, + SUM(si.profit_amount) as total_profit + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + LEFT JOIN sale_items si ON i.id = si.item_id + LEFT JOIN sales s ON si.sale_id = s.id + WHERE s.status = 'completed' ${dateFilter} + GROUP BY i.id, i.name, i.upc, c.name, c.color + ORDER BY total_revenue DESC + LIMIT $${paramIndex} + `; + + params.push(Number(limit)); + const result = await dbQuery(query, params); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching top selling items:', error); + res.status(500).json({ error: 'Failed to fetch top selling items' }); + } +}); + +// Get profit analysis report +router.get('/profit-analysis', [ + query('period').optional().isIn(['7d', '30d', '90d', '1y', 'all']).withMessage('Invalid period'), + query('groupBy').optional().isIn(['day', 'week', 'month', 'category']).withMessage('Invalid group by option') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { period = '30d', groupBy = 'month' } = req.query; + + let dateFilter = ''; + if (period !== 'all') { + const days = { + '7d': 7, + '30d': 30, + '90d': 90, + '1y': 365 + }[period as string]; + + dateFilter = `WHERE s.sale_date >= CURRENT_DATE - INTERVAL '${days} days'`; + } + + let groupByClause = ''; + let selectClause = ''; + + switch (groupBy) { + case 'day': + groupByClause = 'GROUP BY DATE(s.sale_date)'; + selectClause = 'DATE(s.sale_date) as period'; + break; + case 'week': + groupByClause = 'GROUP BY DATE_TRUNC(\'week\', s.sale_date)'; + selectClause = 'DATE_TRUNC(\'week\', s.sale_date) as period'; + break; + case 'month': + groupByClause = 'GROUP BY DATE_TRUNC(\'month\', s.sale_date)'; + selectClause = 'DATE_TRUNC(\'month\', s.sale_date) as period'; + break; + case 'category': + groupByClause = 'GROUP BY c.name, c.color'; + selectClause = 'c.name as period, c.color as period_color'; + break; + } + + const query = ` + SELECT + ${selectClause}, + COUNT(DISTINCT s.id) as total_sales, + SUM(si.total_price) as total_revenue, + SUM(si.total_price - (si.cost_at_sale * si.quantity)) as total_profit, + AVG(si.total_price - (si.cost_at_sale * si.quantity)) as avg_profit_per_sale, + COUNT(si.id) as total_items_sold + FROM sales s + JOIN sale_items si ON s.id = si.sale_id + JOIN items i ON si.item_id = i.id + LEFT JOIN categories c ON i.category_id = c.id + ${dateFilter} + AND s.status = 'completed' + ${groupByClause} + ORDER BY total_profit DESC + `; + + const result = await dbQuery(query); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching profit analysis:', error); + res.status(500).json({ error: 'Failed to fetch profit analysis' }); + } +}); + +// Get low stock report +router.get('/low-stock', [ + query('threshold').optional().isInt({ min: 0 }).withMessage('Threshold must be a non-negative integer') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { threshold } = req.query; + + let whereClause = 'WHERE i.is_active = true'; + const params: any[] = []; + + if (threshold) { + whereClause += ` AND i.quantity_in_stock <= $1`; + params.push(Number(threshold)); + } else { + whereClause += ` AND i.quantity_in_stock <= i.min_stock_level`; + } + + const query = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color, + CASE + WHEN i.quantity_in_stock = 0 THEN 'Out of Stock' + WHEN i.quantity_in_stock <= i.min_stock_level THEN 'Low Stock' + ELSE 'Below Threshold' + END as stock_status + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + ${whereClause} + ORDER BY i.quantity_in_stock ASC, i.name ASC + `; + + const result = await dbQuery(query, params); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching low stock report:', error); + res.status(500).json({ error: 'Failed to fetch low stock report' }); + } +}); + +// Get overstocked items report +router.get('/overstocked', [ + query('threshold').optional().isInt({ min: 1 }).withMessage('Threshold must be a positive integer') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { threshold } = req.query; + + let whereClause = 'WHERE i.is_active = true'; + const params: any[] = []; + + if (threshold) { + whereClause += ` AND i.quantity_in_stock >= $1`; + params.push(Number(threshold)); + } else { + whereClause += ` AND i.max_stock_level IS NOT NULL AND i.quantity_in_stock >= i.max_stock_level`; + } + + const query = ` + SELECT + i.*, + c.name as category_name, + c.color as category_color, + CASE + WHEN i.max_stock_level IS NOT NULL THEN + i.quantity_in_stock - i.max_stock_level + ELSE 0 + END as excess_quantity + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + ${whereClause} + ORDER BY excess_quantity DESC, i.name ASC + `; + + const result = await dbQuery(query, params); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching overstocked report:', error); + res.status(500).json({ error: 'Failed to fetch overstocked report' }); + } +}); + +// Get custom report with dynamic SQL +router.post('/custom', [ + body('query').isString().isLength({ min: 1 }).withMessage('SQL query is required'), + body('params').optional().isArray().withMessage('Params must be an array'), + body('description').optional().isString().withMessage('Description must be a string') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { query: sqlQuery, params = [], description } = req.body; + + // Basic security: only allow SELECT queries + const trimmedQuery = sqlQuery.trim().toLowerCase(); + if (!trimmedQuery.startsWith('select')) { + return res.status(400).json({ error: 'Only SELECT queries are allowed for security reasons' }); + } + + // Prevent dangerous operations + const dangerousKeywords = ['drop', 'delete', 'insert', 'update', 'create', 'alter', 'truncate']; + if (dangerousKeywords.some(keyword => trimmedQuery.includes(keyword))) { + return res.status(400).json({ error: 'Query contains forbidden keywords for security reasons' }); + } + + const result = await dbQuery(sqlQuery, params); + + res.json({ + data: result.rows, + rowCount: result.rowCount, + description, + executedAt: new Date().toISOString() + }); + + } catch (error) { + console.error('Error executing custom query:', error); + res.status(500).json({ + error: 'Failed to execute custom query', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get sales performance report +router.get('/sales-performance', [ + query('startDate').optional().isISO8601().withMessage('Start date must be a valid ISO date'), + query('endDate').optional().isISO8601().withMessage('End date must be a valid ISO date'), + query('groupBy').optional().isIn(['day', 'week', 'month']).withMessage('Invalid group by option') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { startDate, endDate, groupBy = 'day' } = req.query; + + let dateFilter = ''; + const params: any[] = []; + let paramIndex = 1; + + if (startDate && endDate) { + dateFilter = `WHERE s.sale_date >= $${paramIndex} AND s.sale_date <= $${paramIndex + 1}`; + params.push(startDate, endDate); + paramIndex += 2; + } else if (startDate) { + dateFilter = `WHERE s.sale_date >= $${paramIndex}`; + params.push(startDate); + paramIndex++; + } else if (endDate) { + dateFilter = `WHERE s.sale_date <= $${paramIndex}`; + params.push(endDate); + paramIndex++; + } + + let groupByClause = ''; + let selectClause = ''; + + switch (groupBy) { + case 'day': + groupByClause = 'GROUP BY DATE(s.sale_date)'; + selectClause = 'DATE(s.sale_date) as period'; + break; + case 'week': + groupByClause = 'GROUP BY DATE_TRUNC(\'week\', s.sale_date)'; + selectClause = 'DATE_TRUNC(\'week\', s.sale_date) as period'; + break; + case 'month': + groupByClause = 'GROUP BY DATE_TRUNC(\'month\', s.sale_date)'; + selectClause = 'DATE_TRUNC(\'month\', s.sale_date) as period'; + break; + } + + const query = ` + SELECT + ${selectClause}, + COUNT(DISTINCT s.id) as total_sales, + COUNT(si.id) as total_items_sold, + SUM(si.total_price) as total_revenue, + SUM(si.total_price - (si.cost_at_sale * si.quantity)) as total_profit, + AVG(si.total_price) as avg_sale_value, + AVG(si.total_price - (si.cost_at_sale * si.quantity)) as avg_profit_per_sale + FROM sales s + JOIN sale_items si ON s.id = si.sale_id + ${dateFilter} + AND s.status = 'completed' + ${groupByClause} + ORDER BY period ASC + `; + + const result = await dbQuery(query, params); + res.json(result.rows); + + } catch (error) { + console.error('Error fetching sales performance:', error); + res.status(500).json({ error: 'Failed to fetch sales performance' }); + } +}); + +// Export report data to CSV format +router.get('/export/:reportType', [ + param('reportType').isIn(['inventory', 'sales', 'profit', 'low-stock', 'overstocked']).withMessage('Invalid report type'), + query('format').optional().isIn(['json', 'csv']).withMessage('Format must be json or csv') +], async (req: Request, res: Response) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { reportType } = req.params; + const { format = 'json' } = req.query; + + let query = ''; + let params: any[] = []; + + switch (reportType) { + case 'inventory': + query = 'SELECT * FROM inventory_summary ORDER BY name'; + break; + case 'sales': + query = 'SELECT * FROM top_selling_items LIMIT 100'; + break; + case 'profit': + query = 'SELECT * FROM profit_analysis ORDER BY month DESC'; + break; + case 'low-stock': + query = ` + SELECT + i.name, i.upc, i.sku, c.name as category, + i.quantity_in_stock, i.min_stock_level, i.cost, i.sale_price + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + WHERE i.quantity_in_stock <= i.min_stock_level AND i.is_active = true + ORDER BY i.quantity_in_stock ASC + `; + break; + case 'overstocked': + query = ` + SELECT + i.name, i.upc, i.sku, c.name as category, + i.quantity_in_stock, i.max_stock_level, i.cost, i.sale_price + FROM items i + LEFT JOIN categories c ON i.category_id = c.id + WHERE i.max_stock_level IS NOT NULL AND i.quantity_in_stock >= i.max_stock_level AND i.is_active = true + ORDER BY i.quantity_in_stock DESC + `; + break; + } + + const result = await dbQuery(query, params); + + if (format === 'csv') { + // Convert to CSV format + const headers = Object.keys(result.rows[0] || {}).join(','); + const csvData = result.rows.map(row => + Object.values(row).map(value => + typeof value === 'string' && value.includes(',') ? `"${value}"` : value + ).join(',') + ).join('\n'); + + const csv = `${headers}\n${csvData}`; + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${reportType}-${new Date().toISOString().split('T')[0]}.csv"`); + res.send(csv); + } else { + res.json({ + reportType, + data: result.rows, + rowCount: result.rowCount, + exportedAt: new Date().toISOString() + }); + } + + } catch (error) { + console.error('Error exporting report:', error); + res.status(500).json({ error: 'Failed to export report' }); + } +}); + +export default router; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..260412d76 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": false, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "client" + ] +} \ No newline at end of file diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 000000000..a1f5fbbde --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist/server", + "rootDir": "./src/server", + "moduleResolution": "node" + }, + "include": [ + "src/server/**/*" + ], + "exclude": [ + "src/client/**/*" + ] +} \ No newline at end of file