Initial commit: Complete Etsy Business Tracker with Profit Analysis Dashboard

Features:
- React + TypeScript frontend with Tailwind CSS
- Node.js + Express backend with TypeScript
- Comprehensive order tracking and management
- Product catalog with inventory tracking
- Customer data management
- Expense tracking and categorization
- Advanced Profit Analysis Dashboard with:
  - Real-time profit metrics and KPI visualization
  - Detailed order-level profit breakdown
  - Product performance analysis
  - Enhanced time range filtering (monthly, quarterly, yearly)
  - Interactive expandable order analysis
  - Performance categorization and color coding
- CSV import functionality for Etsy statements
- PDF parsing capabilities
- Redux state management with persistence
- Responsive design with mobile support
- Australian date formatting and currency display
This commit is contained in:
dlawler489 2026-04-20 09:44:54 +10:00
commit 9e1a098a70
62 changed files with 24281 additions and 0 deletions

57
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,57 @@
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
# Etsy Business Tracker - Copilot Instructions
## Project Overview
This is a comprehensive Etsy business tracking web application built with React frontend and Node.js backend. The application helps Etsy sellers track products, orders, sales analytics, inventory, customer data, expenses, and profit margins.
## Architecture
- **Frontend**: React with TypeScript, Tailwind CSS for styling
- **Backend**: Node.js with Express, TypeScript
- **Database**: MongoDB for flexible document storage
- **Authentication**: JWT-based authentication
- **State Management**: Redux Toolkit for complex state management
- **Charts/Visualization**: Chart.js for analytics dashboards
## Key Features
1. **Product Management**: Track product listings, variations, pricing, and inventory
2. **Order Tracking**: Monitor order status, fulfillment, and shipping
3. **Sales Analytics**: Revenue tracking, profit margins, trend analysis
4. **Inventory Management**: Stock levels, reorder alerts, supplier tracking
5. **Customer Management**: Customer data, purchase history, communication logs
6. **Expense Tracking**: Business expenses, tax deductions, cost analysis
7. **Financial Reports**: P&L statements, tax reports, business insights
8. **Dashboard**: Real-time metrics and KPI visualization
## Development Guidelines
- Use TypeScript for both frontend and backend
- Follow React functional components with hooks
- Implement proper error handling and validation
- Use responsive design principles with mobile-first approach
- Include comprehensive testing (Jest, React Testing Library)
- Follow RESTful API design principles
- Implement proper security practices (input validation, sanitization)
- Use environment variables for configuration
- Include proper logging and monitoring
## Code Style
- Use ESLint and Prettier for code formatting
- Follow conventional commit messages
- Use descriptive variable and function names
- Include JSDoc comments for complex functions
- Maintain consistent file and folder structure
## Database Schema Considerations
- Products: title, description, price, variants, images, inventory, categories
- Orders: customer info, items, status, shipping, payments, dates
- Customers: contact info, order history, preferences, notes
- Expenses: category, amount, date, description, tax-deductible status
- Analytics: cached metrics, historical data, performance indicators
## Security & Privacy
- Implement proper authentication and authorization
- Sanitize all user inputs
- Use HTTPS in production
- Follow GDPR compliance for customer data
- Implement rate limiting and request validation
- Secure API endpoints with proper middleware

103
.gitignore vendored Normal file
View file

@ -0,0 +1,103 @@
# Dependencies
node_modules/
client/node_modules/
server/node_modules/
# Build outputs
client/dist/
server/dist/
build/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Data files (optional - remove if you want to include sample data)
*.csv
*.pdf
# Local development files
.vscode/settings.json

Binary file not shown.

287
README.md Normal file
View file

@ -0,0 +1,287 @@
# Etsy Business Tracker
A comprehensive web application for tracking and managing your Etsy business operations, including products, orders, customers, analytics, and expenses.
## 🚀 Features
- **Dashboard**: Real-time overview of business metrics and KPIs
- **Product Management**: Track product listings, variations, pricing, and inventory
- **Order Tracking**: Monitor order status, fulfillment, and shipping
- **Customer Management**: Manage customer data, purchase history, and communication
- **Sales Analytics**: Revenue tracking, profit margins, and trend analysis
- **Expense Tracking**: Business expenses, tax deductions, and cost analysis
- **Inventory Management**: Stock levels, reorder alerts, and supplier tracking
- **Financial Reports**: P&L statements, tax reports, and business insights
## 🛠 Tech Stack
### Frontend
- **React 18** with TypeScript
- **Vite** for fast development and building
- **Tailwind CSS** for styling
- **Redux Toolkit** for state management
- **React Router** for navigation
- **Chart.js** for data visualization
- **React Hook Form** with Zod validation
### Backend
- **Node.js** with Express and TypeScript
- **MongoDB** with Mongoose ODM
- **JWT** authentication
- **bcryptjs** for password hashing
- **Helmet** for security
- **Express Rate Limit** for API protection
- **Morgan** for logging
- **Cors** for cross-origin requests
## 📦 Project Structure
```
etsy-tracker/
├── client/ # React frontend
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ ├── pages/ # Page components
│ │ ├── store/ # Redux store and slices
│ │ ├── utils/ # Utility functions
│ │ └── ...
│ ├── public/
│ └── package.json
├── server/ # Node.js backend
│ ├── src/
│ │ ├── controllers/ # Route handlers
│ │ ├── models/ # Database models
│ │ ├── routes/ # API routes
│ │ ├── middleware/ # Custom middleware
│ │ └── index.ts # Server entry point
│ ├── .env.example
│ └── package.json
├── .github/
│ └── copilot-instructions.md
├── package.json # Root package.json
└── README.md
```
## 🚀 Getting Started
### Prerequisites
- Node.js (v18 or higher)
- npm or yarn
- MongoDB (local or Atlas)
### Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd etsy-tracker
```
2. **Install dependencies**
```bash
npm run install:all
```
3. **Set up environment variables**
```bash
cd server
cp .env.example .env
# Edit .env with your MongoDB URI and JWT secrets
```
4. **Start MongoDB**
Make sure MongoDB is running on your local machine or update the `MONGODB_URI` in your `.env` file to point to your MongoDB Atlas cluster.
### Development
**Option 1: Run both servers with one command**
```bash
npm run dev
```
**Option 2: Run servers separately**
Start the backend server:
```bash
npm run server:dev
# Server runs on http://localhost:8080
```
Start the frontend development server:
```bash
npm run client:dev
# Client runs on http://localhost:3000 (or next available port)
```
### Building for Production
```bash
# Build both client and server
npm run build
# Or build separately
npm run client:build
npm run server:build
```
### Running Tests
```bash
npm test
```
## 🔧 Configuration
### Environment Variables (Server)
Create a `.env` file in the `server` directory:
```env
NODE_ENV=development
PORT=8080
CLIENT_URL=http://localhost:3000
MONGODB_URI=mongodb://localhost:27017/etsy-tracker
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
SESSION_SECRET=your-super-secret-session-key-change-this-in-production
```
### Database Setup
The application uses MongoDB to store:
- **Products**: Product details, variants, pricing, inventory
- **Orders**: Order information, customer details, status tracking
- **Customers**: Customer profiles, purchase history
- **Expenses**: Business expense records
- **Users**: Authentication and user management
## 📊 API Endpoints
### Authentication
- `POST /api/auth/register` - User registration
- `POST /api/auth/login` - User login
- `POST /api/auth/logout` - User logout
- `GET /api/auth/me` - Get current user
### Products
- `GET /api/products` - Get all products
- `POST /api/products` - Create new product
- `GET /api/products/:id` - Get specific product
- `PUT /api/products/:id` - Update product
- `DELETE /api/products/:id` - Delete product
### Orders
- `GET /api/orders` - Get all orders
- `POST /api/orders` - Create new order
- `GET /api/orders/:id` - Get specific order
- `PUT /api/orders/:id` - Update order status
### Customers
- `GET /api/customers` - Get all customers
- `POST /api/customers` - Create new customer
- `GET /api/customers/:id` - Get specific customer
- `PUT /api/customers/:id` - Update customer
### Expenses
- `GET /api/expenses` - Get all expenses
- `POST /api/expenses` - Create new expense
- `PUT /api/expenses/:id` - Update expense
- `DELETE /api/expenses/:id` - Delete expense
### Analytics
- `GET /api/analytics/dashboard` - Get dashboard metrics
- `GET /api/analytics/sales` - Get sales analytics
- `GET /api/analytics/products` - Get product performance
- `GET /api/analytics/customers` - Get customer analytics
## 🎨 UI/UX
The application features a modern, responsive design built with Tailwind CSS:
- **Responsive Design**: Works on desktop, tablet, and mobile devices
- **Dark/Light Mode**: User preference support
- **Interactive Charts**: Visual analytics and reporting
- **Form Validation**: Real-time validation with helpful error messages
- **Toast Notifications**: User feedback for actions
## 🔒 Security Features
- **JWT Authentication**: Secure token-based authentication
- **Password Hashing**: bcrypt for secure password storage
- **Rate Limiting**: API endpoint protection
- **CORS Configuration**: Cross-origin request handling
- **Input Validation**: Server-side validation for all inputs
- **Security Headers**: Helmet.js for security headers
## 🚀 Deployment
### Using PM2 (Recommended for production)
```bash
# Build the application
npm run build
# Install PM2 globally
npm install -g pm2
# Start the server with PM2
cd server
pm2 start dist/index.js --name "etsy-tracker-api"
# Serve the client (using a static file server)
pm2 serve client/dist 3000 --name "etsy-tracker-client"
```
### Using Docker
```bash
# Build and run with Docker Compose
docker-compose up --build
```
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/new-feature`
3. Commit your changes: `git commit -am 'Add new feature'`
4. Push to the branch: `git push origin feature/new-feature`
5. Submit a pull request
## 📝 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🆘 Troubleshooting
### Common Issues
1. **Port already in use**: If you get an EADDRINUSE error, kill the process using the port:
```bash
lsof -ti:8080 | xargs kill -9
```
2. **MongoDB connection issues**: Ensure MongoDB is running and the connection string is correct in your `.env` file.
3. **Build errors**: Clear node_modules and reinstall:
```bash
rm -rf node_modules client/node_modules server/node_modules
npm run install:all
```
### VS Code Integration
This project includes VS Code configuration for:
- **Tasks**: Pre-configured build and dev tasks
- **Debugging**: Launch configurations for both client and server
- **Extensions**: Recommended extensions list
- **Settings**: Project-specific settings
## 📞 Support
If you encounter any issues or have questions, please:
1. Check the troubleshooting section above
2. Search existing issues in the repository
3. Create a new issue with detailed information about the problem
---
**Built with ❤️ for Etsy sellers who want to grow their business through better data tracking and analysis.**

13
client/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Etsy Business Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9483
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

53
client/package.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "etsy-tracker-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "jest"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@reduxjs/toolkit": "^1.9.7",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"pdfjs-dist": "^5.6.205",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"react-redux": "^8.1.3",
"react-router-dom": "^6.20.1",
"tailwind-merge": "^2.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.1",
"@types/jest": "^29.5.8",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.1.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

6
client/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

28
client/public/js/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
client/public/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

113
client/public/test-data.js Normal file
View file

@ -0,0 +1,113 @@
// Test data script - run in browser console to add sample data with printing costs
console.log('Adding test data with printing costs...');
// Sample products with printing costs
const sampleProducts = [
{
_id: 'prod1',
title: 'Custom Business Cards',
description: 'Professional business cards with custom design',
price: 25.00,
costOfGoods: 5.00,
printingCost: 3.50,
sku: 'BC001',
stockLevel: 100,
category: 'Business Cards'
},
{
_id: 'prod2',
title: 'Wedding Invitations',
description: 'Elegant wedding invitations with RSVP cards',
price: 45.00,
costOfGoods: 8.00,
printingCost: 6.00,
sku: 'WI001',
stockLevel: 50,
category: 'Invitations'
},
{
_id: 'prod3',
title: 'Photo Prints 8x10',
description: 'High quality photo prints',
price: 15.00,
costOfGoods: 2.00,
printingCost: 4.50,
sku: 'PP001',
stockLevel: 200,
category: 'Photo Prints'
}
];
// Sample orders with printing costs
const sampleOrders = [
{
_id: 'order1',
orderNumber: '1001',
customer: {
name: 'John Smith',
email: 'john@example.com',
address: {
street1: '123 Main St',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US'
}
},
items: [
{
title: 'Custom Business Cards',
quantity: 2,
price: 25.00,
printingCost: 3.50,
costOfGoods: 5.00
}
],
total: 50.00,
status: 'completed',
dateOrdered: new Date().toISOString()
},
{
_id: 'order2',
orderNumber: '1002',
customer: {
name: 'Sarah Johnson',
email: 'sarah@example.com',
address: {
street1: '456 Oak Ave',
city: 'Los Angeles',
state: 'CA',
postalCode: '90210',
country: 'US'
}
},
items: [
{
title: 'Wedding Invitations',
quantity: 1,
price: 45.00,
printingCost: 6.00,
costOfGoods: 8.00
},
{
title: 'Photo Prints 8x10',
quantity: 3,
price: 15.00,
printingCost: 4.50,
costOfGoods: 2.00
}
],
total: 90.00,
status: 'processing',
dateOrdered: new Date(Date.now() - 24*60*60*1000).toISOString()
}
];
// Add to localStorage
localStorage.setItem('etsy-tracker-products', JSON.stringify(sampleProducts));
localStorage.setItem('etsy-tracker-orders', JSON.stringify(sampleOrders));
// Dispatch Redux actions to update state
window.location.reload();
console.log('Test data added! Page will reload to update the state.');

11
client/src/App.css Normal file
View file

@ -0,0 +1,11 @@
.App {
width: 100%;
min-height: 100vh;
}
#root {
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}

41
client/src/App.tsx Normal file
View file

@ -0,0 +1,41 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Provider } from 'react-redux';
import { Toaster } from 'react-hot-toast';
import { store } from './store';
import Layout from './components/Layout';
import Products from './pages/Products';
import Orders from './pages/Orders';
import Analytics from './pages/Analytics';
import ProfitAnalysis from './pages/ProfitAnalysis';
import Expenses from './pages/Expenses';
import Settings from './pages/Settings';
import DataImport from './pages/DataImport';
import Login from './pages/Login';
import './App.css';
function App() {
return (
<Provider store={store}>
<Router>
<div className="App">
<Toaster position="top-right" />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/analytics" replace />} />
<Route path="analytics" element={<Analytics />} />
<Route path="profit-analysis" element={<ProfitAnalysis />} />
<Route path="products" element={<Products />} />
<Route path="orders" element={<Orders />} />
<Route path="data-import" element={<DataImport />} />
<Route path="expenses" element={<Expenses />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</div>
</Router>
</Provider>
);
}
export default App;

View file

@ -0,0 +1,45 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const Layout: React.FC = () => {
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold text-gray-900">Etsy Tracker</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/analytics" className="border-indigo-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Analytics
</a>
<a href="/profit-analysis" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Profit Analysis
</a>
<a href="/products" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Products
</a>
<a href="/orders" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Orders
</a>
<a href="/expenses" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Expenses
</a>
<a href="/data-import" className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
Data Import
</a>
</div>
</div>
</div>
</div>
</nav>
<main>
<Outlet />
</main>
</div>
);
};
export default Layout;

View file

@ -0,0 +1,171 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { addProduct } from '../store/slices/productSlice';
import { X, Plus } from 'lucide-react';
import toast from 'react-hot-toast';
export interface MissingProduct {
title: string;
quantity: number;
price?: number;
size?: string;
orderNumber?: string;
}
interface MissingProductsModalProps {
missingProducts: MissingProduct[];
onClose: () => void;
onComplete: (products: any[]) => void;
}
export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
missingProducts,
onClose,
onComplete
}) => {
const dispatch = useDispatch();
const [productData, setProductData] = useState<{[key: string]: {
printingCost: number;
category: string;
}}>({});
const handleInputChange = (productTitle: string, field: string, value: string) => {
setProductData(prev => ({
...prev,
[productTitle]: {
...prev[productTitle],
[field]: field === 'category' ? value : parseFloat(value) || 0
}
}));
};
const handleAddProducts = () => {
const newProducts: any[] = [];
for (const product of missingProducts) {
const data = productData[product.title] || {};
const printingCost = data.printingCost || 0;
const category = data.category || 'Imported Items';
// Extract size from title if present
const sizeMatch = product.title.match(/Size:\s*([^,\s]+(?:\s+[^,\s]+)*)/i);
const extractedSize = sizeMatch ? sizeMatch[1].trim() : '';
const newProduct = {
_id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
title: product.title,
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
price: product.price || 0, // Default to 0 if price not available (e.g., from packing slip only)
costOfGoods: 0, // Set to 0 since printing cost includes materials
printingCost: printingCost,
sku: `IMP_${Date.now()}_${newProducts.length + 1}`,
stockLevel: 0, // Set to 0 since we don't know stock
category: category,
size: extractedSize,
tags: [],
inventory: { quantity: 0, lowStockAlert: 5 },
isActive: true
};
dispatch(addProduct(newProduct));
newProducts.push(newProduct);
}
toast.success(`Added ${newProducts.length} new products with printing costs (inc. materials)`);
onComplete(newProducts);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Missing Products - Add Printing Costs
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<p className="text-gray-600 mb-4">
The following products from your order are not in your product database.
Please add printing costs (including materials/filament) to complete the import:
</p>
<div className="space-y-4">
{missingProducts.map((product, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Name
</label>
<div className="text-sm text-gray-900 font-medium">
{product.title}
</div>
<div className="text-xs text-gray-500 mt-1">
Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Printing Cost per Item (inc. materials)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
>
<option value="">Select category...</option>
<option value="3D Prints">3D Prints</option>
<option value="Jewelry">Jewelry</option>
<option value="Home Decor">Home Decor</option>
<option value="Organizers">Organizers</option>
<option value="Planters">Planters</option>
<option value="Custom Items">Custom Items</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddProducts}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Add Products to Database
</button>
</div>
</div>
</div>
</div>
);
};

38
client/src/index.css Normal file
View file

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
}

10
client/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1,565 @@
import { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import { calculateOrderPrintingCost, enrichOrderItemsWithCosts } from '../utils/orderCalculations';
import { formatAustralianDate } from '../utils/dateFormatter';
import { TrendingUp, DollarSign, Package, Users, ShoppingCart, Download, Printer } from 'lucide-react';
const Analytics = () => {
const { orders } = useSelector((state: RootState) => state.orders);
const { products } = useSelector((state: RootState) => state.products);
const { customers } = useSelector((state: RootState) => state.customers);
const { expenses } = useSelector((state: RootState) => state.expenses);
const [dateRange, setDateRange] = useState('month');
// Helper function to get updated printing cost using current product costs
const getUpdatedPrintingCost = (order: any) => {
const updatedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
return calculateOrderPrintingCost(updatedItems);
};
// Helper function to filter data by date range
const getDateRangeFilter = (range: string) => {
const now = new Date();
const cutoffDate = new Date();
switch (range) {
case 'week':
cutoffDate.setDate(now.getDate() - 7);
break;
case 'month':
// Get the first day of last month
cutoffDate.setMonth(now.getMonth() - 1);
cutoffDate.setDate(1);
cutoffDate.setHours(0, 0, 0, 0);
break;
case 'quarter':
cutoffDate.setMonth(now.getMonth() - 3);
break;
case 'year':
cutoffDate.setFullYear(now.getFullYear() - 1);
break;
default:
// Default to first day of last month
cutoffDate.setMonth(now.getMonth() - 1);
cutoffDate.setDate(1);
cutoffDate.setHours(0, 0, 0, 0);
}
return cutoffDate;
};
// Filter orders and expenses by selected date range
const filteredOrders = useMemo(() => {
if (!orders) return [];
// Handle specific month selections (e.g., "2026-03" for March 2026)
if (dateRange.match(/^\d{4}-\d{2}$/)) {
const [year, month] = dateRange.split('-').map(Number);
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0, 23, 59, 59);
return orders.filter(order => {
if (!order.dateOrdered) return false;
const orderDate = new Date(order.dateOrdered);
return orderDate >= startOfMonth && orderDate <= endOfMonth;
});
}
const cutoffDate = getDateRangeFilter(dateRange);
if (dateRange === 'month') {
// For month view, show only the previous complete month
const now = new Date();
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
return orders.filter(order => {
if (!order.dateOrdered) return false;
const orderDate = new Date(order.dateOrdered);
return orderDate >= startOfLastMonth && orderDate <= endOfLastMonth;
});
}
return orders.filter(order => {
if (!order.dateOrdered) return false;
const orderDate = new Date(order.dateOrdered);
return orderDate >= cutoffDate;
});
}, [orders, dateRange]);
const filteredExpenses = useMemo(() => {
if (!expenses) return [];
// Handle specific month selections (e.g., "2026-03" for March 2026)
if (dateRange.match(/^\d{4}-\d{2}$/)) {
const [year, month] = dateRange.split('-').map(Number);
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0, 23, 59, 59);
return expenses.filter(expense => {
if (!expense.date) return false;
const expenseDate = new Date(expense.date);
return expenseDate >= startOfMonth && expenseDate <= endOfMonth;
});
}
const cutoffDate = getDateRangeFilter(dateRange);
if (dateRange === 'month') {
// For month view, show only the previous complete month
const now = new Date();
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const endOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
return expenses.filter(expense => {
if (!expense.date) return false;
const expenseDate = new Date(expense.date);
return expenseDate >= startOfLastMonth && expenseDate <= endOfLastMonth;
});
}
return expenses.filter(expense => {
if (!expense.date) return false;
const expenseDate = new Date(expense.date);
return expenseDate >= cutoffDate;
});
}, [expenses, dateRange]);
// Calculate metrics with filtered data
const totalRevenue = filteredOrders.reduce((sum, order) => sum + (order?.total || 0), 0);
const totalPrintingCosts = filteredOrders.reduce((sum, order) => {
return sum + getUpdatedPrintingCost(order);
}, 0);
// Calculate expenses excluding only Etsy transaction fees to avoid double-counting
// (Etsy transaction fees are already deducted from order totals in the CSV)
// But we DO want to include Etsy Ads, Listing Fees, subscriptions, etc.
const totalExpenses = filteredExpenses.reduce((sum, expense) => {
// More comprehensive detection of Etsy transaction fees
const isEtsyTransactionFee = (
expense.category?.toLowerCase() === 'transaction fees' ||
(expense.vendor?.toLowerCase().includes('etsy') &&
(expense.category?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('etsy transaction fee')))
);
if (isEtsyTransactionFee) {
console.log('Excluding transaction fee from profit calc:', expense.description, '$' + expense.amount, 'Category:', expense.category);
return sum; // Skip transaction fees as they're already deducted from order totals
}
return sum + (expense?.amount || 0);
}, 0);
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0;
// Debug logging to verify the separation
console.log('=== EXPENSE SEPARATION DEBUG ===');
console.log('Date range filter:', dateRange);
console.log('Filtered orders count:', filteredOrders.length);
console.log('Filtered expenses count:', filteredExpenses.length);
const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0);
const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => {
const isEtsyTransactionFee = (
expense.category?.toLowerCase() === 'transaction fees' ||
(expense.vendor?.toLowerCase().includes('etsy') &&
(expense.category?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('etsy transaction fee')))
);
if (isEtsyTransactionFee) {
console.log('Found transaction fee:', expense.description, '$' + expense.amount, 'Category:', expense.category);
}
return isEtsyTransactionFee ? sum + (expense.amount || 0) : sum;
}, 0);
console.log('All Expenses Total:', allExpensesTotal);
console.log('Etsy Transaction Fees Total:', transactionFeesTotal);
console.log('Expenses for Profit Calc (excluding transaction fees):', totalExpenses);
console.log('Difference (should equal transaction fees):', allExpensesTotal - totalExpenses);
console.log('Total Revenue:', totalRevenue);
console.log('Total Printing Costs:', totalPrintingCosts);
console.log('Net Profit:', netProfit);
console.log('Profit Margin:', profitMargin.toFixed(1) + '%');
const averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0;
const totalCustomers = customers?.length || 0;
// Create monthly revenue data from actual orders
const monthlyData = useMemo(() => {
if (!filteredOrders.length) return [];
const monthlyMap = new Map();
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Initialize all months with 0
months.forEach(month => {
monthlyMap.set(month, { month, revenue: 0, expenses: 0, profit: 0 });
});
// Aggregate orders by month
filteredOrders.forEach(order => {
if (order.dateOrdered) {
const date = new Date(order.dateOrdered);
const monthName = months[date.getMonth()];
const current = monthlyMap.get(monthName);
if (current) {
current.revenue += order.total || 0;
}
}
});
// Aggregate expenses by month (showing all expenses for visibility)
filteredExpenses.forEach(expense => {
if (expense.date) {
const date = new Date(expense.date);
const monthName = months[date.getMonth()];
const current = monthlyMap.get(monthName);
if (current) {
// For monthly chart, exclude transaction fees to match profit calculation
const isEtsyTransactionFee = (
(expense.vendor?.toLowerCase() === 'etsy' &&
(expense.category?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('transaction fee')))
);
if (!isEtsyTransactionFee) {
current.expenses += expense.amount || 0;
}
}
}
});
// Calculate profit for each month
monthlyMap.forEach(data => {
data.profit = data.revenue - data.expenses;
});
return Array.from(monthlyMap.values()).filter(data => data.revenue > 0 || data.expenses > 0);
}, [filteredOrders, filteredExpenses]);
// Create expense categories data from actual expenses
const expenseCategories = useMemo(() => {
const categories = new Map();
filteredExpenses.forEach(expense => {
// Show ALL expenses in categories (including Etsy transaction fees for visibility)
// The profit calculation will handle excluding transaction fees separately
const category = expense.category || 'Other';
const current = categories.get(category) || 0;
categories.set(category, current + (expense.amount || 0));
});
const totalExpenseAmount = Array.from(categories.values()).reduce((sum, amount) => sum + amount, 0);
const categoryData = Array.from(categories.entries()).map(([category, amount]) => ({
category,
amount,
percentage: totalExpenseAmount > 0 ? (amount / totalExpenseAmount) * 100 : 0
})).sort((a, b) => b.amount - a.amount);
// Debug: Log detailed expense breakdown
console.log('=== DETAILED EXPENSE BREAKDOWN ===');
console.log('Total Expenses for Display (all expenses):', totalExpenseAmount.toFixed(2));
console.log('Total Expenses for Profit Calc (excluding transaction fees):', totalExpenses.toFixed(2));
console.log('Expense categories breakdown:');
categoryData.forEach(cat => {
console.log(` ${cat.category}: $${cat.amount.toFixed(2)} (${cat.percentage.toFixed(1)}%)`);
});
console.log('PROFIT CALCULATION:');
console.log(`Revenue: $${totalRevenue.toFixed(2)}`);
console.log(`Printing Costs: $${totalPrintingCosts.toFixed(2)}`);
console.log(`Other Expenses (excluding transaction fees): $${totalExpenses.toFixed(2)}`);
console.log(`Net Profit: $${totalRevenue.toFixed(2)} - $${totalPrintingCosts.toFixed(2)} - $${totalExpenses.toFixed(2)} = $${netProfit.toFixed(2)}`);
console.log('===');
return categoryData;
}, [filteredExpenses, totalExpenses, totalRevenue, totalPrintingCosts, netProfit]);
const topProducts = (products || [])
.filter(product => product && typeof product.price === 'number' && typeof product.costOfGoods === 'number')
.sort((a, b) => {
const aTotalCosts = a.costOfGoods + (a.printingCost || 0);
const bTotalCosts = b.costOfGoods + (b.printingCost || 0);
return (b.price - bTotalCosts) - (a.price - aTotalCosts);
})
.slice(0, 5);
const recentOrders = filteredOrders
.filter(order => order && order.dateOrdered)
.sort((a, b) => new Date(b.dateOrdered).getTime() - new Date(a.dateOrdered).getTime())
.slice(0, 5);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Analytics</h1>
<p className="text-gray-600 mt-1">Business insights and performance metrics</p>
</div>
<div className="flex gap-3">
<select
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
>
<option value="week">Last Week</option>
<option value="month">Last Month</option>
<option value="quarter">Last Quarter</option>
<option value="year">This Year</option>
<optgroup label="2026 Months">
<option value="2026-04">April 2026</option>
<option value="2026-03">March 2026</option>
<option value="2026-02">February 2026</option>
<option value="2026-01">January 2026</option>
</optgroup>
<optgroup label="2025 Months">
<option value="2025-12">December 2025</option>
<option value="2025-11">November 2025</option>
<option value="2025-10">October 2025</option>
<option value="2025-09">September 2025</option>
<option value="2025-08">August 2025</option>
<option value="2025-07">July 2025</option>
</optgroup>
</select>
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<Download className="w-4 h-4" />
Export Report
</button>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Total Revenue</p>
<p className="text-2xl font-semibold text-gray-900">${totalRevenue.toFixed(2)}</p>
</div>
<div className="flex-shrink-0">
<DollarSign className="h-8 w-8 text-green-500" />
</div>
</div>
<div className="mt-2 flex items-center text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">12.5%</span>
<span className="text-gray-600 ml-1">vs last period</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Net Profit</p>
<p className="text-2xl font-semibold text-gray-900">${netProfit.toFixed(2)}</p>
</div>
<div className="flex-shrink-0">
<TrendingUp className="h-8 w-8 text-blue-500" />
</div>
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-gray-600">Margin: </span>
<span className="text-blue-600 font-medium ml-1">{profitMargin.toFixed(1)}%</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Orders</p>
<p className="text-2xl font-semibold text-gray-900">{filteredOrders.length}</p>
</div>
<div className="flex-shrink-0">
<ShoppingCart className="h-8 w-8 text-purple-500" />
</div>
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-gray-600">AOV: </span>
<span className="text-purple-600 font-medium ml-1">${averageOrderValue.toFixed(2)}</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Customers</p>
<p className="text-2xl font-semibold text-gray-900">{totalCustomers}</p>
</div>
<div className="flex-shrink-0">
<Users className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-gray-600">Repeat rate: </span>
<span className="text-orange-600 font-medium ml-1">
{totalCustomers > 0 && customers ?
((customers.filter(c => c && c.totalOrders > 1).length / totalCustomers) * 100).toFixed(1) :
0}%
</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Printing Costs</p>
<p className="text-2xl font-semibold text-gray-900">${totalPrintingCosts.toFixed(2)}</p>
</div>
<div className="flex-shrink-0">
<Printer className="h-8 w-8 text-indigo-500" />
</div>
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-gray-600">Avg per order: </span>
<span className="text-indigo-600 font-medium ml-1">
${orders?.length > 0 ? (totalPrintingCosts / orders.length).toFixed(2) : '0.00'}
</span>
</div>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Revenue Chart */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Revenue Trend</h3>
<div className="h-64 flex items-end justify-between space-x-2">
{monthlyData.length > 0 ? monthlyData.map((data, index) => {
const maxRevenue = Math.max(...monthlyData.map(d => d.revenue));
const height = maxRevenue > 0 ? (data.revenue / maxRevenue) * 100 : 0;
return (
<div key={index} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-blue-500 rounded-t"
style={{ height: `${height}%`, minHeight: data.revenue > 0 ? '8px' : '0px' }}
title={`${data.month}: $${data.revenue.toFixed(2)}`}
/>
<span className="text-xs text-gray-500 mt-2">{data.month}</span>
</div>
);
}) : (
<div className="flex items-center justify-center w-full h-32 text-gray-500">
No revenue data for selected period
</div>
)}
</div>
<div className="mt-4 flex justify-between text-sm text-gray-600">
<span>Revenue by Month</span>
{monthlyData.length > 0 && (
<span>Peak: ${Math.max(...monthlyData.map(d => d.revenue)).toFixed(2)}</span>
)}
</div>
</div>
{/* Expense Breakdown */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Expense Categories</h3>
<div className="space-y-4">
{expenseCategories.length > 0 ? expenseCategories.map((category, index) => {
const colors = ['bg-blue-600', 'bg-green-600', 'bg-purple-600', 'bg-orange-600', 'bg-red-600', 'bg-indigo-600'];
const colorClass = colors[index % colors.length];
return (
<div key={category.category} className="flex justify-between items-center">
<span className="text-sm text-gray-600">{category.category}</span>
<div className="flex items-center">
<div className="flex flex-col items-center mr-3">
<span className="text-xs text-gray-500 mb-1">${category.amount.toFixed(2)}</span>
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className={`${colorClass} h-2 rounded-full`}
style={{ width: `${category.percentage}%` }}
/>
</div>
</div>
<span className="text-sm font-medium">{category.percentage.toFixed(1)}%</span>
</div>
</div>
);
}) : (
<div className="flex items-center justify-center h-32 text-gray-500">
No expense data for selected period
</div>
)}
</div>
</div>
</div>
{/* Tables Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Products */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Top Profitable Products</h3>
<div className="space-y-3">
{topProducts.length > 0 ? topProducts.map((product, index) => (
<div key={product?._id || index} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-500 mr-3">#{index + 1}</span>
<div>
<p className="text-sm font-medium text-gray-900 truncate max-w-48">{product?.title || 'Unknown Product'}</p>
<p className="text-xs text-gray-500">SKU: {product?.sku || 'N/A'}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-green-600">
${((product?.price || 0) - (product?.costOfGoods || 0) - (product?.printingCost || 0)).toFixed(2)}
</p>
<p className="text-xs text-gray-500">profit per item</p>
</div>
</div>
)) : (
<div className="text-center text-gray-500 py-4">
<p>No products found</p>
</div>
)}
</div>
</div>
{/* Recent Orders */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Recent Orders</h3>
<div className="space-y-3">
{recentOrders.length > 0 ? recentOrders.map((order) => (
<div key={order?._id || Math.random()} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
<div className="flex items-center">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">
<Package className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">#{order?.orderNumber || 'N/A'}</p>
<p className="text-xs text-gray-500">{order?.dateOrdered ? formatAustralianDate(order.dateOrdered) : 'N/A'}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">${(order?.total || 0).toFixed(2)}</p>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
order?.status === 'delivered' ? 'bg-green-100 text-green-800' :
order?.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
order?.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{order?.status || 'unknown'}
</span>
</div>
</div>
)) : (
<div className="text-center text-gray-500 py-4">
<p>No recent orders</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default Analytics;

View file

@ -0,0 +1,199 @@
import { useState } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import { Search, Users, Mail, ShoppingBag, DollarSign } from 'lucide-react';
const Customers = () => {
const { customers } = useSelector((state: RootState) => state.customers);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const filteredCustomers = customers.filter(customer =>
customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.email.toLowerCase().includes(searchTerm.toLowerCase())
);
const sortedCustomers = [...filteredCustomers].sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'totalSpent':
return b.totalSpent - a.totalSpent;
case 'totalOrders':
return b.totalOrders - a.totalOrders;
default:
return 0;
}
});
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Customers</h1>
<p className="text-gray-600 mt-1">Manage your customer relationships</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Users className="h-8 w-8 text-blue-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Total Customers</p>
<p className="text-2xl font-semibold text-gray-900">{customers.length}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<ShoppingBag className="h-8 w-8 text-green-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Avg Orders per Customer</p>
<p className="text-2xl font-semibold text-gray-900">
{customers.length > 0 ? (customers.reduce((sum, c) => sum + c.totalOrders, 0) / customers.length).toFixed(1) : '0'}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<DollarSign className="h-8 w-8 text-purple-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Customer Lifetime Value</p>
<p className="text-2xl font-semibold text-gray-900">
${customers.length > 0 ? (customers.reduce((sum, c) => sum + c.totalSpent, 0) / customers.length).toFixed(2) : '0.00'}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Mail className="h-8 w-8 text-orange-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Repeat Customers</p>
<p className="text-2xl font-semibold text-gray-900">
{customers.filter(c => c.totalOrders > 1).length}
</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6 flex-wrap">
<div className="relative flex-1 min-w-64">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search customers..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="name">Sort by Name</option>
<option value="totalSpent">Sort by Total Spent</option>
<option value="totalOrders">Sort by Order Count</option>
</select>
</div>
{/* Customers Grid */}
<div className="grid gap-6">
{sortedCustomers.length === 0 ? (
<div className="text-center py-12">
<Users className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No customers found</h3>
<p className="text-gray-500 mb-4">Customer data will appear here when you import orders.</p>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Orders
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total Spent
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedCustomers.map((customer) => (
<tr key={customer._id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-medium">
{customer.name.charAt(0).toUpperCase()}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{customer.name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{customer.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{customer.totalOrders}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${customer.totalSpent.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{customer.totalOrders > 1 ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Repeat Customer
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
New Customer
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default Customers;

View file

@ -0,0 +1,29 @@
import React from 'react';
const Dashboard: React.FC = () => {
return (
<div className="p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900">Total Revenue</h3>
<p className="text-2xl font-bold text-green-600">$0.00</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900">Orders</h3>
<p className="text-2xl font-bold text-blue-600">0</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900">Products</h3>
<p className="text-2xl font-bold text-purple-600">0</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900">Customers</h3>
<p className="text-2xl font-bold text-orange-600">0</p>
</div>
</div>
</div>
);
};
export default Dashboard;

View file

@ -0,0 +1,838 @@
import React, { useState, useRef } from 'react';
import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord } from '../utils/csvImportService';
import { pdfParser, ParsedPackingSlip } from '../utils/pdfParser';
import { matchOrderItemsToProducts } from '../utils/productMatcher';
import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal';
import { DataManager } from '../utils/dataManager';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addOrder, updateOrder, setOrders } from '../store/slices/orderSlice';
import { Order } from '../store/slices/orderSlice';
import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { dateTestResults } from '../utils/testDateParsing';
interface ImportResults {
etsyOrders: ParsedEtsyOrder[];
shippingRecords: ParsedShippingRecord[];
matchedData: Array<{
order: ParsedEtsyOrder;
shipping?: ParsedShippingRecord;
confidence: number;
}>;
orderCosts: Array<{
orderNumber: string;
date: string;
productName?: string;
grossRevenue: number;
etsyFees: number;
netRevenue: number;
shippingCost: number;
printingCost: number;
totalCosts: number;
grossProfit: number;
grossMargin: number;
netMargin: number;
shippingConfidence: number;
hasShippingData: boolean;
hasPrintingData: boolean;
}>;
summary: ReturnType<typeof csvImportService.generateSummary>;
}
export default function DataImport() {
// Test date parsing immediately
console.log('=== DATE PARSING TEST RESULTS ===');
console.log('Date test results:', dateTestResults);
const dispatch = useDispatch();
const orders = useSelector((state: RootState) => state.orders.orders);
const products = useSelector((state: RootState) => state.products.products);
// CSV Import State
const [etsyFile, setEtsyFile] = useState<File | null>(null);
const [shippingFile, setShippingFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [results, setResults] = useState<ImportResults | null>(null);
// PDF Import State
const [pdfFiles, setPdfFiles] = useState<File[]>([]);
const [isPdfProcessing, setIsPdfProcessing] = useState(false);
const [pdfResults, setPdfResults] = useState<ParsedPackingSlip[]>([]);
const [showMissingProductsModal, setShowMissingProductsModal] = useState(false);
const [missingProducts, setMissingProducts] = useState<MissingProduct[]>([]);
const [currentOrderForMissing, setCurrentOrderForMissing] = useState<string>('');
// UI State
const [activeTab, setActiveTab] = useState<'csv' | 'pdf'>('csv');
const [error, setError] = useState<string>('');
const etsyFileRef = useRef<HTMLInputElement>(null);
const shippingFileRef = useRef<HTMLInputElement>(null);
const pdfFileRef = useRef<HTMLInputElement>(null);
const handleFileChange = (
event: React.ChangeEvent<HTMLInputElement>,
type: 'etsy' | 'shipping' | 'pdf'
) => {
const files = event.target.files;
if (!files) return;
if (type === 'pdf') {
const pdfFiles = Array.from(files).filter(file => file.type === 'application/pdf');
if (pdfFiles.length > 0) {
setPdfFiles(pdfFiles);
setError('');
} else {
setError('Please select valid PDF files');
}
} else {
const file = files[0];
if (file && file.type === 'text/csv') {
if (type === 'etsy') {
setEtsyFile(file);
} else {
setShippingFile(file);
}
setError('');
} else {
setError('Please select a valid CSV file');
}
}
};
const readFileAsText = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsText(file);
});
};
const processCsvFiles = async () => {
if (!etsyFile) {
setError('Please select an Etsy statement CSV file');
return;
}
setIsLoading(true);
setError('');
try {
const etsyContent = await readFileAsText(etsyFile);
const etsyOrders = csvImportService.parseEtsyStatement(etsyContent);
let shippingRecords: ParsedShippingRecord[] = [];
if (shippingFile) {
const shippingContent = await readFileAsText(shippingFile);
shippingRecords = csvImportService.parseAustraliaPostShipping(shippingContent);
}
const matchedData = csvImportService.matchOrdersWithShipping(etsyOrders, shippingRecords);
const printingCosts = new Map<string, number>();
orders.forEach(order => {
if (order.items) {
const totalPrinting = order.items.reduce((sum, item) =>
sum + (item.printingCost || 0), 0);
if (totalPrinting > 0) {
printingCosts.set(order.orderNumber, totalPrinting);
}
}
});
const orderCosts = csvImportService.calculateOrderCosts(matchedData, printingCosts);
const summary = csvImportService.generateSummary(orderCosts);
setResults({
etsyOrders,
shippingRecords,
matchedData,
orderCosts,
summary
});
// Automatically create orders from CSV data
const csvOrders = etsyOrders.map(csvOrder => {
// Check if order already exists
const existingOrder = orders.find(order => order.orderNumber === csvOrder.orderNumber);
if (existingOrder) {
// Update existing order with CSV revenue data
return {
...existingOrder,
total: csvOrder.saleAmount,
fees: {
etsy: csvOrder.totalFees || 0,
processing: 0,
shipping: 0
}
};
} else {
// Create new order from CSV data
return {
_id: `csv-${csvOrder.orderNumber}`,
orderNumber: csvOrder.orderNumber,
total: csvOrder.saleAmount,
status: 'delivered' as const,
dateOrdered: csvOrder.date,
customer: {
name: 'Etsy Customer',
email: ''
},
items: [{
title: csvOrder.productName || 'Product from Etsy',
quantity: 1,
price: csvOrder.saleAmount,
printingCost: 0
}],
fees: {
etsy: csvOrder.totalFees || 0,
processing: 0,
shipping: 0
}
};
}
});
// Update existing orders and add new ones
const existingOrderNumbers = new Set(orders.map(o => o.orderNumber));
const ordersToUpdate = csvOrders.filter(o => existingOrderNumbers.has(o.orderNumber));
const ordersToAdd = csvOrders.filter(o => !existingOrderNumbers.has(o.orderNumber));
ordersToUpdate.forEach(order => dispatch(updateOrder(order)));
ordersToAdd.forEach(order => dispatch(addOrder(order)));
toast.success(`CSV imported! Created ${ordersToAdd.length} new orders and updated ${ordersToUpdate.length} existing orders.`);
} catch (err) {
console.error('Error processing CSV files:', err);
setError('Error processing CSV files. Please check the file format.');
} finally {
setIsLoading(false);
}
};
const processPdfFiles = async () => {
if (pdfFiles.length === 0) {
setError('Please select PDF packing slip files');
return;
}
setIsPdfProcessing(true);
setError('');
try {
const parsedSlips: ParsedPackingSlip[] = [];
for (const file of pdfFiles) {
console.log(`Processing PDF: ${file.name}`);
const slip = await pdfParser.parsePackingSlip(file);
if (slip) {
parsedSlips.push(slip);
}
}
setPdfResults(parsedSlips);
for (const slip of parsedSlips) {
await createOrUpdateOrderFromSlip(slip);
}
} catch (err) {
console.error('Error processing PDF files:', err);
setError('Error processing PDF files. Please check the file format.');
} finally {
setIsPdfProcessing(false);
}
};
const createOrUpdateOrderFromSlip = async (slip: ParsedPackingSlip, customProducts?: any[]) => {
console.log('Creating/updating order for slip:', slip);
const existingOrder = orders.find(order => order.orderNumber === slip.orderNumber);
// Check if we have CSV results for this order number to get revenue data
let csvOrderData = null;
if (results && results.etsyOrders) {
csvOrderData = results.etsyOrders.find(csvOrder => csvOrder.orderNumber === slip.orderNumber);
console.log('Found matching CSV data for order:', csvOrderData);
}
const productsToUse = customProducts || products;
const { matches, missingProducts: missing } = matchOrderItemsToProducts(slip.items, productsToUse);
if (missing.length > 0) {
setMissingProducts(missing);
setCurrentOrderForMissing(slip.orderNumber);
setShowMissingProductsModal(true);
return;
}
const orderItems = matches.map((match: any) => ({
title: match.orderItem.title,
quantity: match.orderItem.quantity,
price: match.orderItem.price || 0,
printingCost: match.matchedProduct?.printingCost || 0
}));
// Parse and format the order date
let formattedOrderDate = new Date().toISOString();
if (slip.orderDate) {
try {
// Convert "21 Jul, 2025" format to ISO date
const parsedDate = new Date(slip.orderDate);
if (!isNaN(parsedDate.getTime())) {
formattedOrderDate = parsedDate.toISOString();
}
} catch (error) {
console.warn('Failed to parse order date:', slip.orderDate, error);
}
}
const orderData: Order = {
_id: existingOrder?._id || Date.now().toString(),
orderNumber: slip.orderNumber,
total: csvOrderData?.saleAmount || existingOrder?.total || 0, // Use CSV revenue data if available
status: existingOrder?.status || 'processing',
dateOrdered: formattedOrderDate,
customer: existingOrder?.customer || {
name: slip.customerName,
email: slip.customerEmail || '',
},
items: orderItems,
fees: csvOrderData ? {
etsy: csvOrderData.totalFees || 0,
processing: 0,
shipping: 0
} : existingOrder?.fees
};
if (existingOrder) {
dispatch(updateOrder(orderData));
} else {
dispatch(addOrder(orderData));
}
};
const handleMissingProductsSubmit = (newProducts: any[]) => {
// Products are already created by the MissingProductsModal
// We need to use the updated product list for matching
setShowMissingProductsModal(false);
const slip = pdfResults.find(slip => slip.orderNumber === currentOrderForMissing);
if (slip) {
// Use the combined product list (existing + new) for matching
const updatedProducts = [...products, ...newProducts];
createOrUpdateOrderFromSlip(slip, updatedProducts);
}
};
const handleClearTestData = () => {
if (window.confirm('Clear all existing data for testing? This will download a backup first.')) {
DataManager.clearWithBackup();
toast.success('Data cleared and backup downloaded!');
setTimeout(() => window.location.reload(), 1000);
}
};
const handleClearAllOrders = () => {
const orderCount = orders.length;
if (window.confirm(`This will delete all ${orderCount} existing orders. You'll need to re-upload your packing slips to get the correct dates. Are you sure?`)) {
dispatch(setOrders([]));
toast.success(`All ${orderCount} orders cleared! Now re-upload your packing slips with fixed date parsing.`);
}
};
const createOrdersFromCSV = () => {
if (!results || !results.etsyOrders || results.etsyOrders.length === 0) {
toast.error('No CSV data available. Please import Etsy CSV first.');
return;
}
const csvOrders = results.etsyOrders.map(csvOrder => {
// Check if order already exists (from packing slip import)
const existingOrder = orders.find(order => order.orderNumber === csvOrder.orderNumber);
if (existingOrder) {
// Update existing order with CSV revenue data
return {
...existingOrder,
total: csvOrder.saleAmount,
fees: {
etsy: csvOrder.totalFees || 0,
processing: 0,
shipping: 0
}
};
} else {
// Create new order from CSV data (no packing slip available)
return {
_id: `csv-${csvOrder.orderNumber}`,
orderNumber: csvOrder.orderNumber,
total: csvOrder.saleAmount,
status: 'delivered' as const,
dateOrdered: csvOrder.date,
customer: {
name: 'Etsy Customer',
email: ''
},
items: [{
title: csvOrder.productName || 'Product from Etsy',
quantity: 1,
price: csvOrder.saleAmount,
printingCost: 0
}],
fees: {
etsy: csvOrder.totalFees || 0,
processing: 0,
shipping: 0
}
};
}
});
// Update existing orders and add new ones
const existingOrderNumbers = new Set(orders.map(o => o.orderNumber));
const ordersToUpdate = csvOrders.filter(o => existingOrderNumbers.has(o.orderNumber));
const ordersToAdd = csvOrders.filter(o => !existingOrderNumbers.has(o.orderNumber));
ordersToUpdate.forEach(order => dispatch(updateOrder(order)));
ordersToAdd.forEach(order => dispatch(addOrder(order)));
toast.success(`Updated ${ordersToUpdate.length} existing orders and created ${ordersToAdd.length} new orders from CSV data.`);
};
const debugDataState = () => {
console.log('=== DEBUGGING DATA STATE ===');
console.log('Current orders in Redux:', orders.length);
orders.forEach((order, i) => {
console.log(`Order ${i + 1}:`, {
orderNumber: order.orderNumber,
total: order.total,
items: order.items?.length || 0,
fees: order.fees,
customer: order.customer?.name
});
});
console.log('CSV Results:', results ? {
etsyOrders: results.etsyOrders?.length || 0,
sampleOrder: results.etsyOrders?.[0]
} : 'No CSV results');
console.log('Products:', products.length);
toast.success(`Debug info logged to console. Orders: ${orders.length}, CSV: ${results?.etsyOrders?.length || 0}, Products: ${products.length}`);
};
const testActualPDF = async () => {
try {
console.log('=== TESTING PDF PARSER ===');
// First, let's check what products we have in the database
console.log('Current products in database:', products.map(p => ({ id: p._id, title: p.title })));
console.log('Testing actual packing slip PDF...');
const response = await fetch('/3748364725.pdf');
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const file = new File([blob], '3748364725.pdf', { type: 'application/pdf' });
console.log('File size:', file.size, 'bytes');
// Let's also extract the raw text to see what we're working with
const { getDocument } = await import('pdfjs-dist');
const pdf = await getDocument(arrayBuffer).promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item: any) => item.str).join(' ');
fullText += pageText + '\n';
console.log(`Page ${i} text:`, pageText);
}
console.log('\n=== FULL EXTRACTED TEXT ===');
console.log(fullText);
// Now try our parser
const result = await pdfParser.parsePackingSlip(file);
console.log('\n=== PARSER RESULT ===');
console.log('Parse Result:', JSON.stringify(result, null, 2));
if (result.items && result.items.length > 0) {
console.log('\n=== PARSED ITEMS ===');
console.log('Order Number:', result.orderNumber);
result.items.forEach((item, index) => {
console.log(`Item ${index + 1}: ${item.title} (Qty: ${item.quantity})`);
});
} else {
console.log('❌ No items found - we need to update the parser patterns based on the extracted text above');
}
} catch (error) {
console.error('Error testing PDF:', error);
}
};
return (
<div className="p-6">
<div className="mb-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Data Import & Analysis</h1>
<p className="text-gray-600">
Import Etsy statements, Australia Post shipping data, and PDF packing slips for complete business analysis.
</p>
</div>
{/* Testing Helper */}
<div className="flex flex-col gap-2">
<button
onClick={handleClearAllOrders}
className="flex items-center gap-2 px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-sm"
>
<Trash2 className="w-4 h-4" />
Clear All Orders
</button>
<button
onClick={handleClearTestData}
className="flex items-center gap-2 px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-sm"
>
<Trash2 className="w-4 h-4" />
Clear for Testing
</button>
<button
onClick={testActualPDF}
className="flex items-center gap-2 px-3 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-sm"
>
<FileText className="w-4 h-4" />
Test PDF Parser
</button>
<button
onClick={debugDataState}
className="flex items-center gap-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-sm"
>
<Package className="w-4 h-4" />
Debug Data
</button>
<p className="text-xs text-gray-500 max-w-32 text-center">
Fix dates | Clear all | Test parsing | Debug
</p>
</div>
</div>
</div>
{/* Date Fix Notice */}
{orders.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Date Fix Required for Existing Orders
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
You have {orders.length} existing orders that may have incorrect dates (showing today's date instead of actual order dates).
To fix this: <strong>Click "Clear All Orders"</strong> above, then re-upload your packing slip PDFs.
The updated date parsing will now extract the correct order dates.
</p>
</div>
</div>
</div>
</div>
)}
{/* Import Options Tabs */}
<div className="bg-white rounded-lg shadow-md mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs">
<button
className={`${activeTab === 'csv' ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
onClick={() => setActiveTab('csv')}
>
<FileText className="w-5 h-5 inline mr-2" />
CSV Import (Financial Data)
</button>
<button
className={`${activeTab === 'pdf' ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`}
onClick={() => setActiveTab('pdf')}
>
<Package className="w-5 h-5 inline mr-2" />
PDF Import (Packing Slips)
</button>
</nav>
</div>
{/* CSV Import Section */}
{activeTab === 'csv' && (
<div className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center">
<Truck className="w-6 h-6 mr-2 text-blue-600" />
Upload CSV Files for Financial Analysis
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Etsy CSV Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Etsy Statement CSV (Required)
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
<input
type="file"
ref={etsyFileRef}
accept=".csv"
onChange={(e) => handleFileChange(e, 'etsy')}
className="hidden"
/>
<div className="text-center">
{etsyFile ? (
<div className="text-green-600">
<svg className="mx-auto h-12 w-12" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<p className="mt-2 text-sm">{etsyFile.name}</p>
</div>
) : (
<div>
<FileText className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">Click to upload Etsy statement</p>
<p className="text-xs text-gray-500 mt-1">Contains sales, fees, and order data</p>
</div>
)}
<button
onClick={() => etsyFileRef.current?.click()}
className="mt-2 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Select File
</button>
</div>
</div>
</div>
{/* Australia Post CSV Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Australia Post Shipping CSV (Optional)
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
<input
type="file"
ref={shippingFileRef}
accept=".csv"
onChange={(e) => handleFileChange(e, 'shipping')}
className="hidden"
/>
<div className="text-center">
{shippingFile ? (
<div className="text-green-600">
<svg className="mx-auto h-12 w-12" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<p className="mt-2 text-sm">{shippingFile.name}</p>
</div>
) : (
<div>
<Truck className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">Click to upload shipping data</p>
<p className="text-xs text-gray-500 mt-1">Contains actual shipping costs and tracking</p>
</div>
)}
<button
onClick={() => shippingFileRef.current?.click()}
className="mt-2 bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
Select File
</button>
</div>
</div>
</div>
</div>
<div className="mt-6 flex gap-4">
<button
onClick={processCsvFiles}
disabled={!etsyFile || isLoading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
>
{isLoading ? 'Processing...' : 'Analyze Financial Data'}
</button>
</div>
</div>
)}
{/* PDF Import Section */}
{activeTab === 'pdf' && (
<div className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center">
<Package className="w-6 h-6 mr-2 text-purple-600" />
Upload PDF Packing Slips for Item Details
</h2>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
PDF Packing Slips (Multiple files supported)
</label>
<div className="border-2 border-dashed border-purple-300 rounded-lg p-6">
<input
type="file"
ref={pdfFileRef}
accept=".pdf"
multiple
onChange={(e) => handleFileChange(e, 'pdf')}
className="hidden"
/>
<div className="text-center">
{pdfFiles.length > 0 ? (
<div className="text-green-600">
<svg className="mx-auto h-12 w-12" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<p className="mt-2 text-sm">{pdfFiles.length} PDF file(s) selected</p>
<div className="mt-2 max-h-20 overflow-y-auto">
{pdfFiles.map((file, index) => (
<p key={index} className="text-xs text-gray-600">{file.name}</p>
))}
</div>
</div>
) : (
<div>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-lg text-gray-600">Drag & drop PDF packing slips here</p>
<p className="text-sm text-gray-500 mt-1">Or click to browse and select multiple files</p>
<p className="text-xs text-gray-400 mt-2">Extracts item details, quantities, and customer info</p>
</div>
)}
<button
onClick={() => pdfFileRef.current?.click()}
className="mt-4 bg-purple-500 text-white px-6 py-2 rounded-lg hover:bg-purple-600"
>
Select PDF Files
</button>
</div>
</div>
</div>
</div>
<div className="mt-6 flex gap-4">
<button
onClick={processPdfFiles}
disabled={pdfFiles.length === 0 || isPdfProcessing}
className="bg-purple-600 text-white px-6 py-2 rounded-lg hover:bg-purple-700 disabled:bg-gray-400"
>
{isPdfProcessing ? 'Processing PDFs...' : 'Extract Order Details'}
</button>
</div>
{/* PDF Results Display */}
{pdfResults.length > 0 && (
<div className="mt-6 bg-gray-50 rounded-lg p-4">
<h3 className="text-md font-semibold mb-3">PDF Processing Results</h3>
<div className="space-y-2">
{pdfResults.map((slip, index) => (
<div key={index} className="bg-white rounded p-3 shadow-sm">
<p className="font-medium text-gray-900">Order #{slip.orderNumber}</p>
<p className="text-sm text-gray-600">Customer: {slip.customerName}</p>
<p className="text-sm text-gray-600">Items: {slip.items.length}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
{error && (
<div className="mx-6 mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</div>
{/* Results Section - Show for CSV analysis only */}
{results && activeTab === 'csv' && (
<>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500">Total Orders</h3>
<p className="text-2xl font-bold text-gray-900">{results.summary.ordersProcessed}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500">Total Revenue</h3>
<p className="text-2xl font-bold text-green-600">${results.summary.totalRevenue.toFixed(2)}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500">Total Profit</h3>
<p className="text-2xl font-bold text-blue-600">${results.summary.totalProfit.toFixed(2)}</p>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-sm font-medium text-gray-500">Avg Profit Margin</h3>
<p className="text-2xl font-bold text-purple-600">{results.summary.averageGrossMargin.toFixed(1)}%</p>
</div>
</div>
{/* Action Button to Create Orders */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-blue-800">Re-sync Orders from CSV Data</h3>
<p className="text-sm text-blue-600 mt-1">
Orders were automatically created during CSV import. Use this button to re-sync if you need to update the data.
</p>
</div>
<button
onClick={createOrdersFromCSV}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 ml-4"
>
<Package className="w-4 h-4" />
Re-sync Orders
</button>
</div>
</div>
{/* Cost Breakdown */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h3 className="text-lg font-semibold mb-4">Cost Breakdown</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center">
<p className="text-sm text-gray-500">Etsy Fees</p>
<p className="text-xl font-bold text-red-600">${results.summary.totalEtsyFees.toFixed(2)}</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-500">Shipping Costs</p>
<p className="text-xl font-bold text-orange-600">${results.summary.totalShippingCosts.toFixed(2)}</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-500">Printing Costs</p>
<p className="text-xl font-bold text-yellow-600">${results.summary.totalPrintingCosts.toFixed(2)}</p>
</div>
</div>
</div>
</>
)}
{/* Missing Products Modal */}
{showMissingProductsModal && (
<MissingProductsModal
missingProducts={missingProducts}
onClose={() => setShowMissingProductsModal(false)}
onComplete={handleMissingProductsSubmit}
/>
)}
</div>
);
}

View file

@ -0,0 +1,666 @@
import React, { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addExpenses, addExpense, updateExpense, deleteExpense } from '../store/slices/expenseSlice';
import { formatAustralianDate } from '../utils/dateFormatter';
import { Upload, Plus, Search, Edit, Trash2, Receipt, Download, DollarSign } from 'lucide-react';
import toast from 'react-hot-toast';
interface ExpenseFormData {
description: string;
amount: number;
category: string;
date: string;
taxDeductible: boolean;
vendor: string;
receiptUrl?: string;
}
const Expenses = () => {
const dispatch = useDispatch();
const { expenses } = useSelector((state: RootState) => state.expenses);
const [showAddForm, setShowAddForm] = useState(false);
const [editingExpense, setEditingExpense] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [dateRange, setDateRange] = useState('all');
const [showTaxDeductibleOnly, setShowTaxDeductibleOnly] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const etsyFileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<ExpenseFormData>({
description: '',
amount: 0,
category: '',
date: new Date().toISOString().split('T')[0],
taxDeductible: false,
vendor: ''
});
const expenseCategories = [
'Shipping & Postage',
'Materials & Supplies',
'Listing Fees',
'Transaction Fees',
'Payment Processing Fees',
'Marketing & Advertising',
'Taxes & GST',
'Packaging',
'Office Supplies',
'Professional Services',
'Software & Subscriptions',
'Travel & Transport',
'Other'
];
const handleAusPostCSVImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
toast.error('Please select a valid CSV file');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const lines = text.split('\n');
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '').toLowerCase());
toast.loading('Importing expenses from CSV...');
const importedExpenses = [];
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
const expenseData: any = {};
headers.forEach((header, index) => {
const value = values[index] || '';
switch (header) {
case 'transaction date':
case 'date':
case 'posted date':
// Convert DD/MM/YYYY to YYYY-MM-DD format
if (value.includes('/')) {
const [day, month, year] = value.split('/');
expenseData.date = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
} else {
expenseData.date = value;
}
break;
case 'total cost':
case 'amount':
case 'cost':
case 'charge':
const amount = parseFloat(value.replace(/[$,AUD\s-]/g, ''));
expenseData.amount = Math.abs(amount) || 0; // Make positive
break;
case 'tracking number':
case 'transaction id':
case 'reference':
case 'receipt':
expenseData.reference = value;
break;
case 'description':
case 'merchant':
case 'vendor':
expenseData.description = value;
expenseData.vendor = value;
break;
case 'category':
case 'type':
expenseData.category = value || 'Shipping & Postage';
break;
}
});
// Auto-categorize based on description or set default for Australia Post
if (!expenseData.category) {
expenseData.category = 'Shipping & Postage';
}
// If no description, create one from the tracking number or transaction ID
if (!expenseData.description) {
expenseData.description = expenseData.reference ?
`Australia Post - ${expenseData.reference}` :
'Australia Post Shipping';
}
if (!expenseData.vendor) {
expenseData.vendor = 'Australia Post';
}
if (expenseData.amount > 0) {
importedExpenses.push({
_id: `temp-${Date.now()}-${i}`,
description: expenseData.description || 'Imported Expense',
amount: expenseData.amount,
category: expenseData.category || 'Other',
date: expenseData.date || new Date().toISOString().split('T')[0],
taxDeductible: true, // Assume business expenses are tax deductible
vendor: expenseData.vendor || expenseData.description || 'Unknown',
reference: expenseData.reference
});
}
}
// Simulate API call - replace with actual API call
console.log('Imported expenses:', importedExpenses);
// Add the imported expenses to the store
dispatch(addExpenses(importedExpenses));
toast.success(`Successfully imported ${importedExpenses.length} expenses`);
} catch (error) {
console.error('Import error:', error);
toast.error('Failed to import CSV file. Please check the format.');
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleEtsyCSVImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
toast.error('Please use the Data Import page for Etsy CSV files. This ensures proper separation of sales and expenses.');
// Clear the input
if (etsyFileInputRef.current) {
etsyFileInputRef.current.value = '';
}
// Optionally redirect to Data Import page
setTimeout(() => {
window.location.href = '/data-import';
}, 2000);
};
const handleExportCSV = () => {
const headers = [
'Date', 'Description', 'Category', 'Amount', 'Vendor',
'Tax Deductible', 'Reference'
];
const csvData = expenses.map(expense => [
expense.date,
expense.description,
expense.category,
expense.amount.toFixed(2),
expense.vendor || '',
expense.taxDeductible ? 'Yes' : 'No',
expense.reference || ''
]);
const csv = [headers, ...csvData].map(row =>
row.map(field => `"${field}"`).join(',')
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `business-expenses-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
toast.success('Expenses exported successfully');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingExpense) {
// Update existing expense
dispatch(updateExpense({
_id: editingExpense,
...formData
}));
toast.success('Expense updated successfully!');
setEditingExpense(null);
} else {
// Add new expense
dispatch(addExpense({
_id: `expense-${Date.now()}`,
...formData
}));
toast.success('Expense added successfully!');
}
setShowAddForm(false);
setFormData({
description: '',
amount: 0,
category: '',
date: new Date().toISOString().split('T')[0],
taxDeductible: false,
vendor: ''
});
} catch (error) {
toast.error('Failed to save expense');
}
};
const handleEdit = (expense: any) => {
setFormData({
description: expense.description,
amount: expense.amount,
category: expense.category,
date: expense.date,
taxDeductible: expense.taxDeductible,
vendor: expense.vendor || ''
});
setEditingExpense(expense._id);
setShowAddForm(true);
};
const handleDelete = (expenseId: string) => {
if (window.confirm('Are you sure you want to delete this expense?')) {
dispatch(deleteExpense(expenseId));
toast.success('Expense deleted successfully!');
}
};
const handleCancel = () => {
setShowAddForm(false);
setEditingExpense(null);
setFormData({
description: '',
amount: 0,
category: '',
date: new Date().toISOString().split('T')[0],
taxDeductible: false,
vendor: ''
});
};
const filteredExpenses = expenses
.filter(expense => {
const matchesSearch = expense.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
expense.category.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = categoryFilter === 'all' || expense.category === categoryFilter;
const matchesTaxDeductible = !showTaxDeductibleOnly || expense.taxDeductible;
let matchesDate = true;
if (dateRange !== 'all') {
const expenseDate = new Date(expense.date);
const now = new Date();
switch (dateRange) {
case 'week':
matchesDate = (now.getTime() - expenseDate.getTime()) <= 7 * 24 * 60 * 60 * 1000;
break;
case 'month':
matchesDate = (now.getTime() - expenseDate.getTime()) <= 30 * 24 * 60 * 60 * 1000;
break;
case 'quarter':
matchesDate = (now.getTime() - expenseDate.getTime()) <= 90 * 24 * 60 * 60 * 1000;
break;
case 'year':
matchesDate = expenseDate.getFullYear() === now.getFullYear();
break;
}
}
return matchesSearch && matchesCategory && matchesTaxDeductible && matchesDate;
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Sort by newest first
const totalExpenses = filteredExpenses.reduce((sum, expense) => sum + expense.amount, 0);
const taxDeductibleTotal = filteredExpenses.filter(e => e.taxDeductible).reduce((sum, expense) => sum + expense.amount, 0);
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Expenses</h1>
<p className="text-gray-600 mt-1">Track business expenses and tax deductions</p>
</div>
<div className="flex gap-3">
<input
type="file"
ref={fileInputRef}
onChange={handleAusPostCSVImport}
accept=".csv"
style={{ display: 'none' }}
/>
<input
type="file"
ref={etsyFileInputRef}
onChange={handleEtsyCSVImport}
accept=".csv"
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Upload className="w-4 h-4" />
Import Australia Post CSV
</button>
<button
onClick={() => etsyFileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
<Upload className="w-4 h-4" />
Import Etsy CSV
</button>
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<Plus className="w-4 h-4" />
Add Expense
</button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<DollarSign className="h-8 w-8 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Total Expenses</p>
<p className="text-2xl font-semibold text-gray-900">${totalExpenses.toFixed(2)}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Receipt className="h-8 w-8 text-green-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Tax Deductible</p>
<p className="text-2xl font-semibold text-gray-900">${taxDeductibleTotal.toFixed(2)}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6 flex-wrap">
<div className="relative flex-1 min-w-64">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search expenses..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="all">All Categories</option>
{expenseCategories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
<select
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
>
<option value="all">All Time</option>
<option value="week">Last Week</option>
<option value="month">Last Month</option>
<option value="quarter">Last Quarter</option>
<option value="year">This Year</option>
</select>
<label className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg">
<input
type="checkbox"
checked={showTaxDeductibleOnly}
onChange={(e) => setShowTaxDeductibleOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Tax Deductible Only</span>
</label>
</div>
{/* Add Expense Form Modal */}
{showAddForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{editingExpense ? 'Edit Expense' : 'Add New Expense'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount ($)</label>
<input
type="number"
step="0.01"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.amount}
onChange={(e) => setFormData({...formData, amount: parseFloat(e.target.value) || 0})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.category}
onChange={(e) => setFormData({...formData, category: e.target.value})}
>
<option value="">Select Category</option>
{expenseCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input
type="date"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.vendor}
onChange={(e) => setFormData({...formData, vendor: e.target.value})}
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="taxDeductible"
checked={formData.taxDeductible}
onChange={(e) => setFormData({...formData, taxDeductible: e.target.checked})}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="taxDeductible" className="text-sm text-gray-700">
This expense is tax deductible
</label>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingExpense ? 'Update Expense' : 'Add Expense'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Expenses List */}
<div className="grid gap-6">
{filteredExpenses.length === 0 ? (
<div className="text-center py-12">
<Receipt className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No expenses found</h3>
<p className="text-gray-500 mb-4">Import your expense data or add expenses manually.</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Upload className="w-4 h-4" />
Import Australia Post CSV
</button>
<button
onClick={() => etsyFileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
<Upload className="w-4 h-4" />
Import Etsy CSV
</button>
<button
onClick={() => setShowAddForm(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<Plus className="w-4 h-4" />
Add Expense
</button>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="flex items-center gap-1">
Date
<span className="text-xs text-gray-400">( newest first)</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tax Deductible
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredExpenses.map((expense) => (
<tr key={expense._id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatAustralianDate(expense.date)}
</td>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">{expense.description}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{expense.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${expense.amount.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{expense.taxDeductible ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Yes
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
No
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(expense)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(expense._id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default Expenses;

View file

@ -0,0 +1,39 @@
const Login = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<form className="mt-8 space-y-6">
<div>
<input
type="email"
placeholder="Email address"
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<input
type="password"
placeholder="Password"
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign in
</button>
</div>
</form>
</div>
</div>
);
};
export default Login;

955
client/src/pages/Orders.tsx Normal file
View file

@ -0,0 +1,955 @@
import { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../store';
import { updateOrder, deleteOrder, addOrder, Order, setOrders } from '../store/slices/orderSlice';
import { addProduct } from '../store/slices/productSlice';
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from '../utils/orderCalculations';
import { formatAustralianDate } from '../utils/dateFormatter';
import { Search, Package, Download, ArrowRight, Edit, Trash2, X, Plus } from 'lucide-react';
import toast from 'react-hot-toast';
const Orders = () => {
const dispatch = useDispatch();
const { orders } = useSelector((state: RootState) => state.orders);
const { products } = useSelector((state: RootState) => state.products);
// Debug: Monitor order changes
useEffect(() => {
// Clean up orders with invalid IDs on component mount
const cleanupInvalidOrders = () => {
const validOrders = orders?.filter(order => order._id && order._id !== 'undefined') || [];
if (validOrders.length !== orders?.length) {
console.log(`Removing ${(orders?.length || 0) - validOrders.length} orders with invalid IDs`);
dispatch(setOrders(validOrders));
toast.success('Cleaned up orders with invalid IDs');
}
};
if (orders && orders.length > 0) {
cleanupInvalidOrders();
}
}, []); // Only run on mount
const [searchTerm, setSearchTerm] = useState('');
const [dateRange, setDateRange] = useState('all');
// Edit modal state
const [editingOrder, setEditingOrder] = useState<Order | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
// Delete confirmation state
const [deletingOrderId, setDeletingOrderId] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Add manual order state
const [showAddModal, setShowAddModal] = useState(false);
const [showNewProductModal, setShowNewProductModal] = useState(false);
const [newProductData, setNewProductData] = useState({
title: '',
price: 0,
printingCost: 0,
costOfGoods: 0
});
const [newOrder, setNewOrder] = useState({
orderNumber: '',
customer: { name: '', email: '' },
dateOrdered: new Date().toISOString().split('T')[0],
total: 0,
items: [{ title: '', quantity: 1, price: 0 }]
});
// Helper functions to use current product costs
const getUpdatedPrintingCost = (order: Order) => {
const updatedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
return calculateOrderPrintingCost(updatedItems);
};
const getUpdatedProfit = (order: Order) => {
const updatedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
return calculateOrderProfit(updatedItems, order.total || 0);
};
// Sort orders by newest first, then apply filters
const sortedOrders = [...orders].sort((a, b) =>
new Date(b.dateOrdered).getTime() - new Date(a.dateOrdered).getTime()
);
const filteredOrders = sortedOrders.filter(order => {
const matchesSearch = order.orderNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.customer?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.customer?.email?.toLowerCase().includes(searchTerm.toLowerCase());
let matchesDate = true;
if (dateRange !== 'all') {
const orderDate = new Date(order.dateOrdered);
const now = new Date();
// Debug logging
console.log('Filtering order:', {
orderNumber: order.orderNumber,
dateOrdered: order.dateOrdered,
parsedDate: orderDate,
now: now,
dateRange: dateRange
});
const timeDiff = now.getTime() - orderDate.getTime();
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
switch (dateRange) {
case 'week':
matchesDate = daysDiff >= 0 && daysDiff <= 7;
break;
case 'month':
matchesDate = daysDiff >= 0 && daysDiff <= 30;
break;
case 'quarter':
matchesDate = daysDiff >= 0 && daysDiff <= 90;
break;
case 'year':
matchesDate = daysDiff >= 0 && daysDiff <= 365;
break;
}
console.log('Date filter result:', { daysDiff, matchesDate });
}
return matchesSearch && matchesDate;
});
// Financial calculations based on filtered results
const totalPrintingCosts = filteredOrders.reduce((sum, order) => sum + getUpdatedPrintingCost(order), 0);
const handleExportCSV = () => {
const headers = [
'Order Number', 'Customer Name', 'Date', 'Revenue', 'Printing Cost', 'Profit', 'Items'
];
const csvData = filteredOrders.map(order => {
const printingCost = getUpdatedPrintingCost(order);
const profit = getUpdatedProfit(order);
return [
order.orderNumber,
order.customer?.name || '',
formatAustralianDate(order.dateOrdered),
`$${order.total.toFixed(2)}`,
`$${printingCost.toFixed(2)}`,
`$${profit.toFixed(2)}`,
order.items?.map(item => `${item.title} (${item.quantity})`).join('; ') || ''
];
});
const csv = [headers, ...csvData].map((row: string[]) =>
row.map((field: string) => `"${field}"`).join(',')
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `etsy-orders-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
toast.success('Financial data exported successfully');
};
// Handler functions for CRUD operations
const handleEditOrder = (order: Order) => {
setEditingOrder({ ...order });
setShowEditModal(true);
};
const handleSaveOrder = (updatedOrder: Order) => {
dispatch(updateOrder(updatedOrder));
setShowEditModal(false);
setEditingOrder(null);
toast.success('Order updated successfully');
};
const handleDeleteClick = (orderId: string) => {
setDeletingOrderId(orderId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (deletingOrderId && deletingOrderId !== 'undefined') {
dispatch(deleteOrder(deletingOrderId));
setShowDeleteConfirm(false);
setDeletingOrderId(null);
toast.success('Order deleted successfully');
} else {
toast.error('Cannot delete order: Invalid order ID');
setShowDeleteConfirm(false);
setDeletingOrderId(null);
}
};
const handleCancelDelete = () => {
setShowDeleteConfirm(false);
setDeletingOrderId(null);
};
// New product creation handlers
const handleCreateNewProduct = (productTitle: string) => {
setNewProductData({
title: productTitle,
price: 0,
printingCost: 0,
costOfGoods: 0
});
setShowNewProductModal(true);
};
const handleSaveNewProduct = () => {
if (!newProductData.title) {
toast.error('Please enter a product title');
return;
}
const newProduct = {
_id: `product-${Date.now()}`,
title: newProductData.title,
description: '',
price: newProductData.price,
costOfGoods: newProductData.costOfGoods,
printingCost: newProductData.printingCost,
sku: '',
category: '',
tags: [],
inventory: { quantity: 0, lowStockAlert: 10 },
isActive: true
};
dispatch(addProduct(newProduct));
setShowNewProductModal(false);
toast.success('Product created successfully');
};
// Manual order handlers
const handleAddOrder = () => {
setNewOrder({
orderNumber: '',
customer: { name: '', email: '' },
dateOrdered: new Date().toISOString().split('T')[0],
total: 0,
items: [{ title: '', quantity: 1, price: 0 }]
});
setShowAddModal(true);
};
const handleSaveNewOrder = () => {
if (!newOrder.orderNumber || !newOrder.items[0]?.title) {
toast.error('Please fill in order number and at least one item');
return;
}
const calculatedTotal = newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0);
// Enrich items with printing costs from product database
const enrichedItems = newOrder.items
.filter(item => item.title.trim() !== '')
.map(item => {
const matchingProduct = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
return {
...item,
printingCost: matchingProduct?.printingCost || 0,
costOfGoods: matchingProduct?.costOfGoods || 0
};
});
const orderToSave: Order = {
_id: `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // More unique ID
orderNumber: `FB-${newOrder.orderNumber}`, // Prefix to identify Facebook orders
customer: newOrder.customer,
dateOrdered: newOrder.dateOrdered,
total: calculatedTotal,
status: 'delivered' as const,
items: enrichedItems
};
dispatch(addOrder(orderToSave));
setShowAddModal(false);
toast.success('Facebook Marketplace order added successfully');
};
const handleAddItem = () => {
setNewOrder(prev => ({
...prev,
items: [...prev.items, { title: '', quantity: 1, price: 0 }]
}));
};
const handleRemoveItem = (index: number) => {
if (newOrder.items.length > 1) {
setNewOrder(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index)
}));
}
};
const updateNewOrderItem = (index: number, field: string, value: any) => {
setNewOrder(prev => ({
...prev,
items: prev.items.map((item, i) =>
i === index ? { ...item, [field]: value } : item
)
}));
};
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Financial Overview</h1>
<p className="text-gray-600 mt-1">Track revenue, costs, and profit from your Etsy orders</p>
</div>
<div className="flex gap-3">
<button
onClick={handleAddOrder}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<Plus className="w-4 h-4" />
Add Manual Order
</button>
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<Download className="w-4 h-4" />
Export Financial Data
</button>
<a
href="/data-import"
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<ArrowRight className="w-4 h-4" />
Import Data
</a>
</div>
</div>
{/* Financial Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-1 max-w-md gap-6 mb-6">
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-orange-100">
<Package className="w-6 h-6 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Printing Costs</p>
<p className="text-2xl font-bold text-gray-900">${totalPrintingCosts.toFixed(2)}</p>
</div>
</div>
</div>
</div>
{/* Import Instructions */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="text-blue-800 font-semibold mb-2">💡 Need to import order data?</h3>
<p className="text-blue-700 text-sm">
Use the <a href="/data-import" className="font-semibold underline">Data Import</a> page to upload:
<span className="ml-2"> Etsy CSV statements</span>
<span className="ml-2"> Australia Post shipping data</span>
<span className="ml-2"> PDF packing slips</span>
</p>
</div>
{/* Search and Filters */}
<div className="bg-white p-4 rounded-lg shadow mb-6">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-64">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search by order number or customer..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">All Time</option>
<option value="week">Last Week</option>
<option value="month">Last Month</option>
<option value="quarter">Last Quarter</option>
<option value="year">Last Year</option>
</select>
</div>
</div>
{/* Financial Data Table */}
<div className="grid gap-6">
{filteredOrders.length === 0 ? (
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No orders found</h3>
<p className="text-gray-500 mb-4">Import your sales data using the Data Import page.</p>
<div className="flex gap-3 justify-center">
<a
href="/data-import"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<ArrowRight className="w-4 h-4" />
Go to Data Import
</a>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="flex items-center gap-1">
Order Details
<span className="text-xs text-gray-400">( newest first)</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Items
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Revenue
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Printing Cost
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Profit
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Margin %
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredOrders.map((order) => {
const printingCost = getUpdatedPrintingCost(order);
const profit = getUpdatedProfit(order);
const margin = order.total > 0 ? ((profit / order.total) * 100) : 0;
// Use order number as fallback key if _id is undefined
const orderKey = order._id || `fallback-${order.orderNumber}`;
return (
<tr key={orderKey} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
#{order.orderNumber}
</div>
<div className="text-sm text-gray-500">
{formatAustralianDate(order.dateOrdered)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{order.customer?.name || 'Unknown'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{order.items && order.items.length > 0 ? (
order.items.map((item, index) => (
<div key={index} className="mb-1">
<span className="font-medium">{item.title}</span>
<span className="text-gray-500 ml-2">× {item.quantity}</span>
</div>
))
) : (
<span className="text-gray-500 italic">No items listed</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${order.total.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
${printingCost.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<span className={profit >= 0 ? 'text-green-600' : 'text-red-600'}>
${profit.toFixed(2)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`font-medium ${margin >= 20 ? 'text-green-600' : margin >= 10 ? 'text-yellow-600' : 'text-red-600'}`}>
{margin.toFixed(1)}%
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<button
onClick={() => handleEditOrder(order)}
className="text-indigo-600 hover:text-indigo-900 transition-colors"
title="Edit Order"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => order._id ? handleDeleteClick(order._id) : toast.error('Cannot delete order: Missing ID')}
className={`transition-colors ${
order._id
? 'text-red-600 hover:text-red-900'
: 'text-gray-400 cursor-not-allowed'
}`}
title={order._id ? "Delete Order" : "Cannot delete: Missing ID"}
disabled={!order._id}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Edit Order Modal */}
{showEditModal && editingOrder && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Edit Order</h3>
<button
onClick={() => setShowEditModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Order Number</label>
<input
type="text"
value={editingOrder.orderNumber}
onChange={(e) => setEditingOrder({...editingOrder, orderNumber: e.target.value})}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Customer Name</label>
<input
type="text"
value={editingOrder.customer?.name || ''}
onChange={(e) => setEditingOrder({
...editingOrder,
customer: { ...editingOrder.customer!, name: e.target.value }
})}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Total Amount</label>
<input
type="number"
step="0.01"
value={editingOrder.total}
onChange={(e) => setEditingOrder({...editingOrder, total: parseFloat(e.target.value) || 0})}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={editingOrder.status}
onChange={(e) => setEditingOrder({...editingOrder, status: e.target.value as Order['status']})}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Order Date</label>
<input
type="date"
value={editingOrder.dateOrdered ? new Date(editingOrder.dateOrdered).toISOString().split('T')[0] : ''}
onChange={(e) => setEditingOrder({...editingOrder, dateOrdered: new Date(e.target.value).toISOString()})}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => setShowEditModal(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={() => handleSaveOrder(editingOrder)}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700"
>
Save Changes
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3 text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<h3 className="text-lg font-medium text-gray-900 mt-2">Delete Order</h3>
<div className="mt-2 px-7 py-3">
<p className="text-sm text-gray-500">
Are you sure you want to delete this order? This action cannot be undone.
</p>
</div>
<div className="flex justify-center space-x-3 mt-4">
<button
onClick={handleCancelDelete}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={handleConfirmDelete}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
{/* Manual Order Creation Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Add Facebook Marketplace Order</h2>
<button
onClick={() => setShowAddModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Order Details */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Order Number</label>
<input
type="text"
value={newOrder.orderNumber}
onChange={(e) => setNewOrder({...newOrder, orderNumber: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="FB-12345"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Order Date</label>
<input
type="date"
value={newOrder.dateOrdered}
onChange={(e) => setNewOrder({...newOrder, dateOrdered: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Customer Name</label>
<input
type="text"
value={newOrder.customer.name}
onChange={(e) => setNewOrder({...newOrder, customer: {...newOrder.customer, name: e.target.value}})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Customer Name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Customer Email (optional)</label>
<input
type="email"
value={newOrder.customer.email}
onChange={(e) => setNewOrder({...newOrder, customer: {...newOrder.customer, email: e.target.value}})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="customer@email.com"
/>
</div>
</div>
{/* Order Items */}
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-900">Order Items</h3>
<button
onClick={handleAddItem}
className="flex items-center gap-2 px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
>
<Plus className="w-4 h-4" />
Add Item
</button>
</div>
<div className="space-y-3">
{newOrder.items.map((item, index) => {
const matchingProduct = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
return (
<div key={`item-${index}-${item.title || 'empty'}`} className="p-3 bg-gray-50 rounded-lg">
<div className="flex gap-3 items-center mb-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1">Product</label>
<div className="flex gap-2">
<select
value={item.title}
onChange={(e) => {
if (e.target.value === '__CREATE_NEW__') {
handleCreateNewProduct('');
} else {
const selectedProduct = products?.find(p => p.title === e.target.value);
updateNewOrderItem(index, 'title', e.target.value);
if (selectedProduct) {
// Auto-fill price if available
if (selectedProduct.price && selectedProduct.price > 0) {
updateNewOrderItem(index, 'price', selectedProduct.price);
}
}
}
}}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Select a product...</option>
{products?.map(product => (
<option key={product._id} value={product.title}>
{product.title} {product.price ? `($${product.price.toFixed(2)})` : ''}
</option>
))}
<option value="__CREATE_NEW__">+ Create New Product</option>
</select>
</div>
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-600 mb-1">Qty</label>
<input
type="number"
value={item.quantity}
onChange={(e) => updateNewOrderItem(index, 'quantity', parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-center"
min="1"
/>
</div>
<div className="w-28">
<label className="block text-xs font-medium text-gray-600 mb-1">Sale Price</label>
<input
type="number"
step="0.01"
value={item.price}
onChange={(e) => updateNewOrderItem(index, 'price', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
{newOrder.items.length > 1 && (
<button
onClick={() => handleRemoveItem(index)}
className="text-red-600 hover:text-red-700 ml-2"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Show printing cost info if product is selected */}
{matchingProduct && (
<div className="mt-2 p-2 bg-blue-50 rounded text-sm text-blue-700">
<span className="font-medium">Printing Cost: ${matchingProduct.printingCost?.toFixed(2) || '0.00'}</span>
<span className="ml-4">Profit per item: ${(item.price - (matchingProduct.printingCost || 0)).toFixed(2)}</span>
</div>
)}
</div>
);
})}
</div>
{/* Total Display with Printing Cost Breakdown */}
<div className="mt-4 space-y-3">
<div className="p-3 bg-blue-50 rounded-lg">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-700">Order Total (Revenue):</span>
<span className="text-xl font-bold text-blue-600">
${newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0).toFixed(2)}
</span>
</div>
</div>
<div className="p-3 bg-orange-50 rounded-lg">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-700">Total Printing Cost:</span>
<span className="text-lg font-bold text-orange-600">
${newOrder.items.reduce((sum, item) => {
const product = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
return sum + (item.quantity * (product?.printingCost || 0));
}, 0).toFixed(2)}
</span>
</div>
</div>
<div className="p-3 bg-green-50 rounded-lg">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-700">Estimated Profit:</span>
<span className="text-xl font-bold text-green-600">
${(newOrder.items.reduce((sum, item) => sum + (item.quantity * item.price), 0) -
newOrder.items.reduce((sum, item) => {
const product = products?.find(p => p.title.toLowerCase() === item.title.toLowerCase());
return sum + (item.quantity * (product?.printingCost || 0));
}, 0)).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={handleSaveNewOrder}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Save Order
</button>
</div>
</div>
</div>
)}
{/* New Product Creation Modal */}
{showNewProductModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900">Create New Product</h2>
<button
onClick={() => setShowNewProductModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Product Name</label>
<input
type="text"
value={newProductData.title}
onChange={(e) => setNewProductData({...newProductData, title: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Enter product name"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Sale Price</label>
<input
type="number"
step="0.01"
value={newProductData.price}
onChange={(e) => setNewProductData({...newProductData, price: parseFloat(e.target.value) || 0})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Printing Cost</label>
<input
type="number"
step="0.01"
value={newProductData.printingCost}
onChange={(e) => setNewProductData({...newProductData, printingCost: parseFloat(e.target.value) || 0})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Cost of Goods (optional)</label>
<input
type="number"
step="0.01"
value={newProductData.costOfGoods}
onChange={(e) => setNewProductData({...newProductData, costOfGoods: parseFloat(e.target.value) || 0})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="0.00"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowNewProductModal(false)}
className="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={handleSaveNewProduct}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Create Product
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Orders;

View file

@ -0,0 +1,597 @@
import React, { useState, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../store';
import { updateProduct, deleteProduct } from '../store/slices/productSlice';
import { Upload, Plus, Search, Edit, Trash2, Package } from 'lucide-react';
import toast from 'react-hot-toast';
interface ProductFormData {
title: string;
description: string;
price: number;
costOfGoods: number;
printingCost: number;
sku: string;
category: string;
tags: string;
inventory: {
quantity: number;
lowStockAlert: number;
};
}
const Products = () => {
const { products } = useSelector((state: RootState) => state.products);
const dispatch = useDispatch();
const [showAddForm, setShowAddForm] = useState(false);
const [editingProduct, setEditingProduct] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<ProductFormData>({
title: '',
description: '',
price: 0,
costOfGoods: 0,
printingCost: 0,
sku: '',
category: '',
tags: '',
inventory: {
quantity: 0,
lowStockAlert: 5
}
});
const categories = ['Jewelry', 'Accessories', 'Home & Living', 'Clothing', 'Art', 'Craft Supplies', 'Other'];
const handleCSVImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
toast.error('Please select a valid CSV file');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const lines = text.split('\n');
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
toast.loading('Importing products from CSV...');
const importedProducts = [];
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
const productData: any = {
printingCost: 0 // Default printing cost
};
headers.forEach((header, index) => {
const value = values[index] || '';
switch (header.toLowerCase()) {
case 'title':
case 'product title':
case 'listing title':
productData.title = value;
break;
case 'description':
productData.description = value;
break;
case 'price':
case 'listing price':
productData.price = parseFloat(value.replace(/[$,]/g, '')) || 0;
break;
case 'cost':
case 'cost of goods':
case 'cogs':
productData.costOfGoods = parseFloat(value.replace(/[$,]/g, '')) || 0;
break;
case 'printing cost':
case 'print cost':
case 'printing':
productData.printingCost = parseFloat(value.replace(/[$,]/g, '')) || 0;
break;
case 'sku':
productData.sku = value || `SKU-${Date.now()}-${i}`;
break;
case 'category':
productData.category = value || 'Other';
break;
case 'tags':
productData.tags = value.split(';').map((t: string) => t.trim());
break;
case 'quantity':
case 'stock':
case 'inventory':
productData.quantity = parseInt(value) || 0;
break;
}
});
if (productData.title) {
importedProducts.push({
...productData,
inventory: {
quantity: productData.quantity || 0,
lowStockAlert: 5
},
tags: productData.tags || [],
isActive: true
});
}
}
// Simulate API call - replace with actual API call
console.log('Imported products:', importedProducts);
toast.success(`Successfully imported ${importedProducts.length} products`);
} catch (error) {
console.error('Import error:', error);
toast.error('Failed to import CSV file');
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (editingProduct) {
handleUpdateProduct();
} else {
handleAddProduct();
}
};
const handleAddProduct = async () => {
try {
// Simulate API call - replace with actual API call
const newProduct = {
...formData,
_id: `temp-${Date.now()}`,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
isActive: true
};
console.log('New product:', newProduct);
toast.success('Product added successfully');
setShowAddForm(false);
setFormData({
title: '',
description: '',
price: 0,
costOfGoods: 0,
printingCost: 0,
sku: '',
category: '',
tags: '',
inventory: { quantity: 0, lowStockAlert: 5 }
});
} catch (error) {
toast.error('Failed to add product');
}
};
const handleEditProduct = (productId: string) => {
const product = products.find(p => p._id === productId);
if (product) {
setFormData({
title: product.title,
description: product.description || '',
price: product.price,
costOfGoods: product.costOfGoods,
printingCost: product.printingCost || 0,
sku: product.sku || '',
category: product.category || '',
tags: product.tags?.join(', ') || '',
inventory: {
quantity: product.inventory?.quantity || 0,
lowStockAlert: product.inventory?.lowStockAlert || 5
}
});
setEditingProduct(productId);
setShowAddForm(true);
}
};
const handleUpdateProduct = async () => {
if (!editingProduct) return;
try {
const updatedProduct = {
_id: editingProduct,
...formData,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t),
isActive: true
};
dispatch(updateProduct(updatedProduct));
toast.success('Product updated successfully');
setShowAddForm(false);
setEditingProduct(null);
setFormData({
title: '',
description: '',
price: 0,
costOfGoods: 0,
printingCost: 0,
sku: '',
category: '',
tags: '',
inventory: { quantity: 0, lowStockAlert: 5 }
});
} catch (error) {
toast.error('Failed to update product');
}
};
const handleDeleteProduct = (productId: string) => {
setShowDeleteConfirm(productId);
};
const confirmDeleteProduct = () => {
if (showDeleteConfirm) {
dispatch(deleteProduct(showDeleteConfirm));
toast.success('Product deleted successfully');
setShowDeleteConfirm(null);
}
};
const cancelDelete = () => {
setShowDeleteConfirm(null);
};
const cancelEdit = () => {
setShowAddForm(false);
setEditingProduct(null);
setFormData({
title: '',
description: '',
price: 0,
costOfGoods: 0,
printingCost: 0,
sku: '',
category: '',
tags: '',
inventory: { quantity: 0, lowStockAlert: 5 }
});
};
const filteredProducts = products.filter(product => {
const matchesSearch = product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.sku.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'all' || product.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Products</h1>
<p className="text-gray-600 mt-1">Manage your product inventory and listings</p>
</div>
<div className="flex gap-3">
<input
type="file"
ref={fileInputRef}
onChange={handleCSVImport}
accept=".csv"
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Upload className="w-4 h-4" />
Import CSV
</button>
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<Plus className="w-4 h-4" />
Add Product
</button>
</div>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6">
<div className="relative flex-1 max-w-md">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search products..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
<option value="all">All Categories</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
{/* Add Product Form Modal */}
{showAddForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{editingProduct ? 'Edit Product' : 'Add New Product'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SKU</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.sku}
onChange={(e) => setFormData({...formData, sku: e.target.value})}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Price ($)</label>
<input
type="number"
step="0.01"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.price}
onChange={(e) => setFormData({...formData, price: parseFloat(e.target.value) || 0})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Cost of Goods ($)</label>
<input
type="number"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.costOfGoods}
onChange={(e) => setFormData({...formData, costOfGoods: parseFloat(e.target.value) || 0})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Printing Cost per Item ($)</label>
<input
type="number"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.printingCost}
onChange={(e) => setFormData({...formData, printingCost: parseFloat(e.target.value) || 0})}
placeholder="Enter printing cost per item"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.category}
onChange={(e) => setFormData({...formData, category: e.target.value})}
>
<option value="">Select Category</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tags (comma separated)</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="handmade, jewelry, silver"
value={formData.tags}
onChange={(e) => setFormData({...formData, tags: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
<input
type="number"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.inventory.quantity}
onChange={(e) => setFormData({...formData, inventory: {...formData.inventory, quantity: parseInt(e.target.value) || 0}})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Low Stock Alert</label>
<input
type="number"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.inventory.lowStockAlert}
onChange={(e) => setFormData({...formData, inventory: {...formData.inventory, lowStockAlert: parseInt(e.target.value) || 5}})}
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={cancelEdit}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingProduct ? 'Update Product' : 'Add Product'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Products Grid */}
<div className="grid gap-6">
{filteredProducts.length === 0 ? (
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No products found</h3>
<p className="text-gray-500 mb-4">Get started by adding your first product or importing from CSV.</p>
<button
onClick={() => setShowAddForm(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Add Your First Product
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredProducts.map((product) => (
<div key={product._id} className="bg-white rounded-lg shadow border p-6">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="font-semibold text-lg text-gray-900 mb-1">{product.title}</h3>
<p className="text-sm text-gray-500">SKU: {product.sku}</p>
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mt-1">
{product.category}
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleEditProduct(product._id)}
className="p-1 text-gray-400 hover:text-blue-600"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteProduct(product._id)}
className="p-1 text-gray-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Price:</span>
<span className="font-medium">${product.price.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Material Cost:</span>
<span>${product.costOfGoods.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Printing Cost:</span>
<span>${(product.printingCost || 0).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Profit per Item:</span>
<span className="font-medium text-green-600">
${(product.price - product.costOfGoods - (product.printingCost || 0)).toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Stock:</span>
<span className={`font-medium ${product.inventory.quantity <= product.inventory.lowStockAlert ? 'text-red-600' : 'text-green-600'}`}>
{product.inventory.quantity}
</span>
</div>
</div>
{product.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{product.tags.slice(0, 3).map((tag, idx) => (
<span key={idx} className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
{tag}
</span>
))}
{product.tags.length > 3 && (
<span className="text-xs text-gray-400">+{product.tags.length - 3} more</span>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Confirm Delete</h3>
<p className="text-gray-600 mb-6">
Are you sure you want to delete this product? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={cancelDelete}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={confirmDeleteProduct}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Products;

View file

@ -0,0 +1,505 @@
import { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import ProfitAnalysisService, { DateRangeOption } from '../utils/profitAnalysisService';
import { TrendingUp, DollarSign, Package, Target, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
const ProfitAnalysis = () => {
const { orders } = useSelector((state: RootState) => state.orders);
const { products } = useSelector((state: RootState) => state.products);
const [dateRange, setDateRange] = useState('all');
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders'>('overview');
const [expandedOrder, setExpandedOrder] = useState<string | null>(null);
const [orderSortBy, setOrderSortBy] = useState<'date' | 'profit' | 'margin' | 'revenue'>('date');
const [orderFilterCategory, setOrderFilterCategory] = useState<'all' | 'excellent' | 'good' | 'average' | 'poor' | 'loss'>('all');
// Generate date range options based on actual order data
const dateRangeOptions = useMemo(() => {
return ProfitAnalysisService.generateDateRangeOptions(orders || []);
}, [orders]);
// Filter orders by date range
const filteredOrders = useMemo(() => {
return ProfitAnalysisService.filterOrdersByDateRange(orders || [], dateRange);
}, [orders, dateRange]);
// Calculate profit metrics using the service
const profitMetrics = useMemo(() => {
return ProfitAnalysisService.calculateProfitMetrics(filteredOrders, products || []);
}, [filteredOrders, products]);
// Top performing products analysis
const productPerformance = useMemo(() => {
return ProfitAnalysisService.getTopPerformingProducts(filteredOrders, products || [], 10);
}, [filteredOrders, products]);
// Order analysis with detailed breakdown
const orderAnalyses = useMemo(() => {
return ProfitAnalysisService.analyzeOrderProfitability(filteredOrders, products || []);
}, [filteredOrders, products]);
// Filter and sort orders based on selection
const filteredAndSortedOrders = useMemo(() => {
let filtered = orderAnalyses;
// Filter by category
if (orderFilterCategory !== 'all') {
filtered = orderAnalyses.filter(order => order.profitCategory === orderFilterCategory);
}
// Sort orders
filtered = [...filtered].sort((a, b) => {
switch (orderSortBy) {
case 'date':
return new Date(b.date).getTime() - new Date(a.date).getTime();
case 'profit':
return b.profit - a.profit;
case 'margin':
return b.margin - a.margin;
case 'revenue':
return b.revenue - a.revenue;
default:
return 0;
}
});
return filtered;
}, [orderAnalyses, orderFilterCategory, orderSortBy]);
// Helper functions
const getCategoryColor = (category: string) => {
switch (category) {
case 'excellent': return 'bg-green-100 text-green-800';
case 'good': return 'bg-blue-100 text-blue-800';
case 'average': return 'bg-yellow-100 text-yellow-800';
case 'poor': return 'bg-orange-100 text-orange-800';
case 'loss': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const formatCurrency = (amount: number) => {
return `$${amount.toFixed(2)}`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-AU', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Profit Analysis Dashboard</h1>
<p className="text-gray-600 mt-1">Track profit margins, costs, and business performance</p>
</div>
<div className="flex gap-3">
<select
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent min-w-[160px]"
>
{dateRangeOptions.map((option: DateRangeOption, index: number) => (
<option key={index} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* View Selector */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setSelectedView('overview')}
className={`px-4 py-2 rounded-lg font-medium ${
selectedView === 'overview'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Overview
</button>
<button
onClick={() => setSelectedView('trends')}
className={`px-4 py-2 rounded-lg font-medium ${
selectedView === 'trends'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Trends
</button>
<button
onClick={() => setSelectedView('products')}
className={`px-4 py-2 rounded-lg font-medium ${
selectedView === 'products'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Products
</button>
<button
onClick={() => setSelectedView('orders')}
className={`px-4 py-2 rounded-lg font-medium ${
selectedView === 'orders'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Orders
</button>
</div>
{/* Overview Metrics Cards */}
{selectedView === 'overview' && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Total Revenue */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100">
<DollarSign className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Revenue</p>
<p className="text-2xl font-bold text-gray-900">${profitMetrics.totalRevenue.toFixed(2)}</p>
</div>
</div>
</div>
{/* Total Profit */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100">
<TrendingUp className="w-6 h-6 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Profit</p>
<p className="text-2xl font-bold text-gray-900">${profitMetrics.totalProfit.toFixed(2)}</p>
</div>
</div>
</div>
{/* Average Margin */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-purple-100">
<Target className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Average Margin</p>
<p className="text-2xl font-bold text-gray-900">{profitMetrics.averageMargin.toFixed(1)}%</p>
</div>
</div>
</div>
{/* Printing Costs */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="p-3 rounded-full bg-orange-100">
<Package className="w-6 h-6 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Printing Costs</p>
<p className="text-2xl font-bold text-gray-900">${profitMetrics.totalPrintingCosts.toFixed(2)}</p>
</div>
</div>
</div>
</div>
{/* Profit Summary */}
<div className="bg-white p-6 rounded-lg shadow mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Profit Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<p className="text-3xl font-bold text-green-600">{profitMetrics.orderCount}</p>
<p className="text-gray-600 mt-1">Total Orders</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-blue-600">{profitMetrics.profitableOrders}</p>
<p className="text-gray-600 mt-1">Profitable Orders</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-purple-600">
{profitMetrics.orderCount > 0 ? ((profitMetrics.profitableOrders / profitMetrics.orderCount) * 100).toFixed(1) : 0}%
</p>
<p className="text-gray-600 mt-1">Success Rate</p>
</div>
</div>
</div>
</>
)}
{/* Product Performance */}
{selectedView === 'products' && (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Top Performing Products</h3>
<p className="text-gray-600 mt-1">Products ranked by total profit</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Revenue
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cost
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Profit
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Margin
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Orders
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Quantity
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{productPerformance.map((product) => (
<tr key={product.name}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="text-sm font-medium text-gray-900">{product.name}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
${product.revenue.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
${product.cost.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`text-sm font-medium ${
product.profit >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
${product.profit.toFixed(2)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
product.margin >= 30 ? 'bg-green-100 text-green-800' :
product.margin >= 15 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{product.margin.toFixed(1)}%
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.orders}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.quantity}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Placeholder for trends view */}
{selectedView === 'trends' && (
<div className="bg-white p-8 rounded-lg shadow text-center">
<BarChart3 className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Profit Trends</h3>
<p className="text-gray-600">Interactive charts coming soon...</p>
</div>
)}
{/* Order Analysis */}
{selectedView === 'orders' && (
<>
{/* Filter and Sort Controls */}
<div className="bg-white p-4 rounded-lg shadow mb-6">
<div className="flex flex-wrap gap-4 items-center">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Filter by Performance</label>
<select
value={orderFilterCategory}
onChange={(e) => setOrderFilterCategory(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Orders</option>
<option value="excellent">Excellent (50%+ margin)</option>
<option value="good">Good (30-50% margin)</option>
<option value="average">Average (15-30% margin)</option>
<option value="poor">Poor (0-15% margin)</option>
<option value="loss">Loss Making (&lt;0% margin)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sort by</label>
<select
value={orderSortBy}
onChange={(e) => setOrderSortBy(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="date">Date (Newest First)</option>
<option value="profit">Profit (Highest First)</option>
<option value="margin">Margin (Highest First)</option>
<option value="revenue">Revenue (Highest First)</option>
</select>
</div>
<div className="ml-auto">
<p className="text-sm text-gray-600">
Showing {filteredAndSortedOrders.length} of {orderAnalyses.length} orders
</p>
</div>
</div>
</div>
{/* Orders List */}
<div className="space-y-4">
{filteredAndSortedOrders.map(order => (
<div key={order.orderId} className="bg-white rounded-lg shadow">
{/* Order Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => setExpandedOrder(
expandedOrder === order.orderId ? null : order.orderId
)}
className="text-gray-400 hover:text-gray-600"
>
{expandedOrder === order.orderId ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
<div>
<h3 className="text-lg font-medium text-gray-900">
#{order.orderNumber}
</h3>
<p className="text-sm text-gray-500">
{order.customerName} {formatDate(order.date)}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<p className="text-sm text-gray-500">Revenue</p>
<p className="font-medium text-gray-900">{formatCurrency(order.revenue)}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Costs</p>
<p className="font-medium text-gray-900">{formatCurrency(order.printingCosts)}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Profit</p>
<p className={`font-medium ${
order.profit >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatCurrency(order.profit)}
</p>
</div>
<div>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
getCategoryColor(order.profitCategory)
}`}>
{order.margin.toFixed(1)}%
</span>
</div>
</div>
</div>
</div>
{/* Expanded Item Details */}
{expandedOrder === order.orderId && (
<div className="p-4">
<h4 className="font-medium text-gray-900 mb-3">Order Items Breakdown</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Qty
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Revenue
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cost
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Profit
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Margin
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{order.items.map((item, index) => (
<tr key={index}>
<td className="px-4 py-2 text-sm text-gray-900">{item.title}</td>
<td className="px-4 py-2 text-sm text-gray-900">{item.quantity}</td>
<td className="px-4 py-2 text-sm text-gray-900">{formatCurrency(item.price)}</td>
<td className="px-4 py-2 text-sm text-gray-900">{formatCurrency(item.itemRevenue)}</td>
<td className="px-4 py-2 text-sm text-gray-900">{formatCurrency(item.itemCost)}</td>
<td className={`px-4 py-2 text-sm font-medium ${
item.itemProfit >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatCurrency(item.itemProfit)}
</td>
<td className="px-4 py-2 text-sm text-gray-900">
{item.itemMargin.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
))}
{filteredAndSortedOrders.length === 0 && (
<div className="bg-white p-8 rounded-lg shadow text-center">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Orders Found</h3>
<p className="text-gray-600">No orders match your current filter criteria.</p>
</div>
)}
</div>
</>
)}
</div>
);
};
export default ProfitAnalysis;

View file

@ -0,0 +1,193 @@
import { useState, useEffect } from 'react';
import { DataManager } from '../utils/dataManager';
import { Trash2, Download, RefreshCw, AlertTriangle, Database } from 'lucide-react';
import toast from 'react-hot-toast';
const Settings = () => {
const [storageSummary, setStorageSummary] = useState({
orders: 0,
products: 0,
customers: 0,
expenses: 0,
totalItems: 0
});
const [showClearConfirm, setShowClearConfirm] = useState(false);
useEffect(() => {
updateStorageSummary();
}, []);
const updateStorageSummary = () => {
setStorageSummary(DataManager.getStorageSummary());
};
const handleClearData = () => {
DataManager.clearAllData();
updateStorageSummary();
setShowClearConfirm(false);
toast.success('All data cleared successfully!');
// Reload the page to reset Redux state
window.location.reload();
};
const handleClearWithBackup = () => {
DataManager.clearWithBackup();
updateStorageSummary();
setShowClearConfirm(false);
toast.success('Data cleared and backup downloaded!');
// Reload the page to reset Redux state
window.location.reload();
};
const handleExportData = () => {
const backup = DataManager.exportAllData();
const blob = new Blob([backup], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `etsy-tracker-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
window.URL.revokeObjectURL(url);
toast.success('Data exported successfully!');
};
return (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Settings</h1>
{/* Data Management Section */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Database className="w-6 h-6" />
Data Management
</h2>
{/* Current Data Summary */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="text-lg font-medium text-gray-900 mb-3">Current Data Storage</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{storageSummary.orders}</p>
<p className="text-sm text-gray-600">Orders</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{storageSummary.products}</p>
<p className="text-sm text-gray-600">Products</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">{storageSummary.customers}</p>
<p className="text-sm text-gray-600">Customers</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-orange-600">{storageSummary.expenses}</p>
<p className="text-sm text-gray-600">Expenses</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-200 text-center">
<p className="text-lg font-semibold text-gray-900">
Total Items: <span className="text-blue-600">{storageSummary.totalItems}</span>
</p>
</div>
</div>
{/* Action Buttons */}
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<button
onClick={updateStorageSummary}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<RefreshCw className="w-4 h-4" />
Refresh Count
</button>
<button
onClick={handleExportData}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<Download className="w-4 h-4" />
Export Data
</button>
<button
onClick={() => setShowClearConfirm(true)}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
Clear All Data
</button>
</div>
{/* Testing Helper */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-blue-800 font-semibold mb-2">🧪 Testing Mode</h4>
<p className="text-blue-700 text-sm mb-3">
Clear all data to test the import functionality with fresh data. Your data will be backed up automatically.
</p>
<button
onClick={handleClearWithBackup}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Download className="w-4 h-4" />
Clear Data + Download Backup
</button>
</div>
</div>
</div>
{/* Clear Confirmation Modal */}
{showClearConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md mx-4">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-8 h-8 text-red-600" />
<h3 className="text-lg font-semibold text-gray-900">Clear All Data?</h3>
</div>
<p className="text-gray-600 mb-4">
This will permanently delete all your orders, products, customers, and expenses.
This action cannot be undone.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<p className="text-yellow-800 text-sm">
<strong>Current data:</strong> {storageSummary.totalItems} total items
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowClearConfirm(false)}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={handleClearWithBackup}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Clear + Backup
</button>
<button
onClick={handleClearData}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Clear Only
</button>
</div>
</div>
</div>
)}
{/* Other Settings Sections */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Application Settings</h2>
<p className="text-gray-600">Additional application settings will be available here in future updates.</p>
</div>
</div>
);
};
export default Settings;

25
client/src/store/index.ts Normal file
View file

@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit';
import authSlice from './slices/authSlice';
import productSlice from './slices/productSlice';
import orderSlice from './slices/orderSlice';
import customerSlice from './slices/customerSlice';
import expenseSlice from './slices/expenseSlice';
export const store = configureStore({
reducer: {
auth: authSlice,
products: productSlice,
orders: orderSlice,
customers: customerSlice,
expenses: expenseSlice,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View file

@ -0,0 +1,48 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface AuthState {
isAuthenticated: boolean;
user: any | null;
token: string | null;
loading: boolean;
error: string | null;
}
const initialState: AuthState = {
isAuthenticated: false,
user: null,
token: null,
loading: false,
error: null,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginStart: (state) => {
state.loading = true;
state.error = null;
},
loginSuccess: (state, action: PayloadAction<{ user: any; token: string }>) => {
state.isAuthenticated = true;
state.user = action.payload.user;
state.token = action.payload.token;
state.loading = false;
state.error = null;
},
loginFailure: (state, action: PayloadAction<string>) => {
state.loading = false;
state.error = action.payload;
},
logout: (state) => {
state.isAuthenticated = false;
state.user = null;
state.token = null;
state.error = null;
},
},
});
export const { loginStart, loginSuccess, loginFailure, logout } = authSlice.actions;
export default authSlice.reducer;

View file

@ -0,0 +1,52 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Customer {
_id: string;
name: string;
email: string;
totalOrders: number;
totalSpent: number;
}
interface CustomerState {
customers: Customer[];
loading: boolean;
error: string | null;
}
const initialState: CustomerState = {
customers: JSON.parse(localStorage.getItem('customers') || '[]'),
loading: false,
error: null,
};
const customerSlice = createSlice({
name: 'customers',
initialState,
reducers: {
setCustomers: (state, action: PayloadAction<Customer[]>) => {
state.customers = action.payload;
localStorage.setItem('customers', JSON.stringify(state.customers));
},
addCustomer: (state, action: PayloadAction<Customer>) => {
state.customers.push(action.payload);
localStorage.setItem('customers', JSON.stringify(state.customers));
},
updateCustomer: (state, action: PayloadAction<Customer>) => {
const index = state.customers.findIndex(c => c._id === action.payload._id);
if (index !== -1) {
state.customers[index] = action.payload;
localStorage.setItem('customers', JSON.stringify(state.customers));
}
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
},
});
export const { setCustomers, addCustomer, updateCustomer, setLoading, setError } = customerSlice.actions;
export default customerSlice.reducer;

View file

@ -0,0 +1,63 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Expense {
_id: string;
description: string;
amount: number;
category: string;
date: string;
taxDeductible: boolean;
vendor?: string;
reference?: string;
}
interface ExpenseState {
expenses: Expense[];
loading: boolean;
error: string | null;
}
const initialState: ExpenseState = {
expenses: JSON.parse(localStorage.getItem('expenses') || '[]'),
loading: false,
error: null,
};
const expenseSlice = createSlice({
name: 'expenses',
initialState,
reducers: {
setExpenses: (state, action: PayloadAction<Expense[]>) => {
state.expenses = action.payload;
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
addExpenses: (state, action: PayloadAction<Expense[]>) => {
state.expenses.push(...action.payload);
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
addExpense: (state, action: PayloadAction<Expense>) => {
state.expenses.push(action.payload);
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
updateExpense: (state, action: PayloadAction<Expense>) => {
const index = state.expenses.findIndex(e => e._id === action.payload._id);
if (index !== -1) {
state.expenses[index] = action.payload;
localStorage.setItem('expenses', JSON.stringify(state.expenses));
}
},
deleteExpense: (state, action: PayloadAction<string>) => {
state.expenses = state.expenses.filter(e => e._id !== action.payload);
localStorage.setItem('expenses', JSON.stringify(state.expenses));
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
},
});
export const { setExpenses, addExpenses, addExpense, updateExpense, deleteExpense, setLoading, setError } = expenseSlice.actions;
export default expenseSlice.reducer;

View file

@ -0,0 +1,83 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Order {
_id: string;
orderNumber: string;
total: number;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
dateOrdered: string;
customer?: {
name: string;
email: string;
address?: {
street1: string;
city: string;
state: string;
zipCode: string;
country: string;
};
};
items?: {
title: string;
quantity: number;
price: number;
printingCost?: number;
costOfGoods?: number;
}[];
fees?: {
etsy: number;
processing: number;
shipping?: number;
};
}
interface OrderState {
orders: Order[];
loading: boolean;
error: string | null;
}
const initialState: OrderState = {
orders: JSON.parse(localStorage.getItem('orders') || '[]'),
loading: false,
error: null,
};
const orderSlice = createSlice({
name: 'orders',
initialState,
reducers: {
setOrders: (state, action: PayloadAction<Order[]>) => {
state.orders = action.payload;
localStorage.setItem('orders', JSON.stringify(state.orders));
},
addOrder: (state, action: PayloadAction<Order>) => {
state.orders.push(action.payload);
localStorage.setItem('orders', JSON.stringify(state.orders));
},
addOrders: (state, action: PayloadAction<Order[]>) => {
state.orders.push(...action.payload);
localStorage.setItem('orders', JSON.stringify(state.orders));
},
updateOrder: (state, action: PayloadAction<Order>) => {
const index = state.orders.findIndex(o => o._id === action.payload._id);
if (index !== -1) {
state.orders[index] = action.payload;
localStorage.setItem('orders', JSON.stringify(state.orders));
}
},
deleteOrder: (state, action: PayloadAction<string>) => {
state.orders = state.orders.filter(o => o._id !== action.payload);
localStorage.setItem('orders', JSON.stringify(state.orders));
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
},
});
export const { setOrders, addOrder, addOrders, updateOrder, deleteOrder, setLoading, setError } = orderSlice.actions;
export default orderSlice.reducer;

View file

@ -0,0 +1,65 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface Product {
_id: string;
title: string;
description: string;
price: number;
costOfGoods: number;
printingCost: number;
sku: string;
category: string;
tags: string[];
inventory: {
quantity: number;
lowStockAlert: number;
};
isActive: boolean;
}
interface ProductState {
products: Product[];
loading: boolean;
error: string | null;
}
const initialState: ProductState = {
products: JSON.parse(localStorage.getItem('products') || '[]'),
loading: false,
error: null,
};
const productSlice = createSlice({
name: 'products',
initialState,
reducers: {
setProducts: (state, action: PayloadAction<Product[]>) => {
state.products = action.payload;
localStorage.setItem('products', JSON.stringify(state.products));
},
addProduct: (state, action: PayloadAction<Product>) => {
state.products.push(action.payload);
localStorage.setItem('products', JSON.stringify(state.products));
},
updateProduct: (state, action: PayloadAction<Product>) => {
const index = state.products.findIndex(p => p._id === action.payload._id);
if (index !== -1) {
state.products[index] = action.payload;
localStorage.setItem('products', JSON.stringify(state.products));
}
},
deleteProduct: (state, action: PayloadAction<string>) => {
state.products = state.products.filter(p => p._id !== action.payload);
localStorage.setItem('products', JSON.stringify(state.products));
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
},
});
export const { setProducts, addProduct, updateProduct, deleteProduct, setLoading, setError } = productSlice.actions;
export default productSlice.reducer;

View file

@ -0,0 +1,404 @@
export interface EtsyStatementRecord {
Date: string;
Type: 'Sale' | 'Fee' | 'GST' | 'Marketing';
Title: string;
Info: string;
Currency: string;
Amount: string;
'Fees & Taxes': string;
Net: string;
'Tax Details': string;
}
export interface AustraliaPostRecord {
'Transaction ID': string;
'Transaction date': string;
'Tracking number': string;
'Total cost': string;
'Total cost excl. GST': string;
'Total GST': string;
}
export interface ParsedEtsyOrder {
orderNumber: string;
date: string;
saleAmount: number;
totalFees: number;
netAmount: number;
productName?: string;
shippingFee?: number;
}
export interface ParsedShippingRecord {
transactionId: string;
date: string;
trackingNumber: string;
totalCost: number;
costExclGST: number;
gst: number;
}
export class CSVImportService {
/**
* Parse CSV text into array of objects
*/
private parseCSV(csvText: string): Record<string, string>[] {
const lines = csvText.trim().split('\n');
const headers = this.parseCSVLine(lines[0]);
return lines.slice(1).map(line => {
const values = this.parseCSVLine(line);
const record: Record<string, string> = {};
headers.forEach((header, index) => {
record[header] = values[index] || '';
});
return record;
});
}
/**
* Parse a single CSV line handling quoted values
*/
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Double quote escape
current += '"';
i += 2;
} else {
// Toggle quote state
inQuotes = !inQuotes;
i++;
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
i++;
} else {
current += char;
i++;
}
}
result.push(current.trim());
return result;
}
/**
* Parse Etsy statement CSV and extract order information
* Handles the format: Date,Type,Title,Info,Currency,Amount,"Fees & Taxes",Net,"Tax Details"
*/
parseEtsyStatement(csvText: string): ParsedEtsyOrder[] {
const records = this.parseCSV(csvText) as unknown as EtsyStatementRecord[];
const orderMap = new Map<string, ParsedEtsyOrder>();
records.forEach(record => {
if (record.Type === 'Sale') {
// Extract order number from Title like "Payment for Order #3748364725"
const orderMatch = record.Title.match(/Payment for Order #(\d+)/);
if (!orderMatch) return;
const orderNumber = orderMatch[1];
const saleAmount = this.parseAmount(record.Amount);
// Create or update order record
if (!orderMap.has(orderNumber)) {
orderMap.set(orderNumber, {
orderNumber,
date: this.parseDate(record.Date),
saleAmount: saleAmount,
totalFees: 0, // Will be calculated from separate fee records
netAmount: saleAmount
});
} else {
// If order already exists, add to sale amount (multiple items)
const order = orderMap.get(orderNumber)!;
order.saleAmount += saleAmount;
order.netAmount = order.saleAmount - order.totalFees;
}
}
});
// Second pass to calculate fees per order
records.forEach(record => {
if (record.Type === 'Fee' || record.Type === 'GST') {
// Try to match fees to orders by date proximity and context
const feeAmount = Math.abs(this.parseAmount(record.Net));
const feeDate = record.Date;
// Find the closest order by date
let closestOrder: ParsedEtsyOrder | null = null;
let closestDateDiff = Infinity;
for (const order of orderMap.values()) {
const dateDiff = Math.abs(new Date(order.date).getTime() - new Date(feeDate).getTime());
if (dateDiff < closestDateDiff && dateDiff <= 7 * 24 * 60 * 60 * 1000) { // Within 7 days
closestDateDiff = dateDiff;
closestOrder = order;
}
}
if (closestOrder) {
closestOrder.totalFees += feeAmount;
closestOrder.netAmount = closestOrder.saleAmount - closestOrder.totalFees;
// Check for shipping fees
if (record.Title.toLowerCase().includes('shipping')) {
closestOrder.shippingFee = feeAmount;
}
}
}
});
return Array.from(orderMap.values()).filter(order => order.saleAmount > 0);
}
/**
* Parse Australia Post shipping CSV
*/
parseAustraliaPostShipping(csvText: string): ParsedShippingRecord[] {
const records = this.parseCSV(csvText) as unknown as AustraliaPostRecord[];
return records.map(record => ({
transactionId: record['Transaction ID'],
date: this.parseAustraliaPostDate(record['Transaction date']),
trackingNumber: this.cleanTrackingNumber(record['Tracking number']),
totalCost: parseFloat(record['Total cost']) || 0,
costExclGST: parseFloat(record['Total cost excl. GST']) || 0,
gst: parseFloat(record['Total GST']) || 0
}));
}
/**
* Match Etsy orders with Australia Post shipping records
* Uses date proximity and cost analysis for matching
*/
matchOrdersWithShipping(
etsyOrders: ParsedEtsyOrder[],
shippingRecords: ParsedShippingRecord[]
): Array<{
order: ParsedEtsyOrder;
shipping?: ParsedShippingRecord;
confidence: number;
}> {
const results = etsyOrders.map(order => {
const orderDate = new Date(order.date);
let bestMatch: ParsedShippingRecord | undefined;
let bestScore = 0;
shippingRecords.forEach(shipping => {
const shippingDate = new Date(shipping.date);
const daysDiff = Math.abs((orderDate.getTime() - shippingDate.getTime()) / (1000 * 60 * 60 * 24));
// Shipping should be within 7 days of order (usually after)
if (daysDiff <= 7) {
let score = Math.max(0, 1 - (daysDiff / 7)); // Date proximity score
// Bonus if shipping date is after order date (expected)
if (shippingDate >= orderDate) {
score *= 1.2;
}
// Consider shipping cost vs order value (rough heuristic)
const shippingToOrderRatio = shipping.totalCost / order.saleAmount;
if (shippingToOrderRatio >= 0.05 && shippingToOrderRatio <= 0.5) {
score *= 1.1; // Reasonable shipping cost
}
if (score > bestScore) {
bestScore = score;
bestMatch = shipping;
}
}
});
return {
order,
shipping: bestMatch,
confidence: bestScore
};
});
return results;
}
/**
* Calculate comprehensive order costs combining three data sources:
* - Etsy CSV: Revenue, fees, and basic order info
* - Australia Post CSV: Shipping costs
* - Packing Slip PDF: Item details for printing cost calculation (via product matching)
*
* This replicates the spreadsheet functions used for financial tracking
*
* @param matchedData Orders matched with shipping data
* @param printingCosts Map of orderNumber -> printing cost (calculated from packing slips)
* @returns Complete financial picture for each order matching spreadsheet calculations
*/
calculateOrderCosts(matchedData: Array<{
order: ParsedEtsyOrder;
shipping?: ParsedShippingRecord;
confidence: number;
}>, printingCosts: Map<string, number> = new Map()): Array<{
orderNumber: string;
date: string;
productName?: string;
// Revenue breakdown (from Etsy CSV)
grossRevenue: number; // Original sale amount
etsyFees: number; // Transaction fees
netRevenue: number; // Revenue after Etsy fees
// Cost breakdown
shippingCost: number; // From Australia Post CSV
printingCost: number; // From packing slips -> product matching
totalCosts: number; // All costs combined
// Profit analysis (spreadsheet formulas)
grossProfit: number; // Net Revenue - Total Costs
grossMargin: number; // (Gross Profit / Gross Revenue) * 100
netMargin: number; // (Gross Profit / Net Revenue) * 100
// Data quality indicators
shippingConfidence: number;
hasShippingData: boolean;
hasPrintingData: boolean;
}> {
return matchedData.map(({ order, shipping, confidence }) => {
// Revenue calculations (from Etsy CSV)
const grossRevenue = order.saleAmount;
const etsyFees = order.totalFees;
const netRevenue = order.netAmount;
// Cost calculations (from multiple sources)
const shippingCost = shipping ? shipping.totalCost : 0;
const printingCost = printingCosts.get(order.orderNumber) || 0;
const totalCosts = etsyFees + shippingCost + printingCost;
// Profit calculations (replicating spreadsheet formulas)
const grossProfit = grossRevenue - totalCosts;
const grossMargin = grossRevenue > 0 ? (grossProfit / grossRevenue) * 100 : 0;
const netMargin = netRevenue > 0 ? (grossProfit / netRevenue) * 100 : 0;
return {
orderNumber: order.orderNumber,
date: order.date,
productName: order.productName,
// Revenue
grossRevenue,
etsyFees,
netRevenue,
// Costs
shippingCost,
printingCost,
totalCosts,
// Profits (spreadsheet-style calculations)
grossProfit,
grossMargin,
netMargin,
// Data quality
shippingConfidence: confidence,
hasShippingData: shipping !== undefined,
hasPrintingData: printingCost > 0
};
});
}
/**
* Generate summary statistics matching spreadsheet calculations
*/
generateSummary(orderCosts: ReturnType<CSVImportService['calculateOrderCosts']>) {
const totalRevenue = orderCosts.reduce((sum, order) => sum + order.grossRevenue, 0);
const totalCosts = orderCosts.reduce((sum, order) => sum + order.totalCosts, 0);
const totalProfit = orderCosts.reduce((sum, order) => sum + order.grossProfit, 0);
const averageGrossMargin = orderCosts.length > 0
? orderCosts.reduce((sum, order) => sum + order.grossMargin, 0) / orderCosts.length
: 0;
const shippingMatched = orderCosts.filter(order => order.shippingConfidence > 0.5).length;
const shippingMatchRate = orderCosts.length > 0 ? (shippingMatched / orderCosts.length) * 100 : 0;
const printingDataAvailable = orderCosts.filter(order => order.hasPrintingData).length;
const printingDataRate = orderCosts.length > 0 ? (printingDataAvailable / orderCosts.length) * 100 : 0;
return {
// Financial Summary (spreadsheet-style)
totalRevenue,
totalEtsyFees: orderCosts.reduce((sum, order) => sum + order.etsyFees, 0),
totalShippingCosts: orderCosts.reduce((sum, order) => sum + order.shippingCost, 0),
totalPrintingCosts: orderCosts.reduce((sum, order) => sum + order.printingCost, 0),
totalCosts,
totalProfit,
averageGrossMargin,
averageNetMargin: orderCosts.length > 0
? orderCosts.reduce((sum, order) => sum + order.netMargin, 0) / orderCosts.length
: 0,
// Data Quality Metrics
ordersProcessed: orderCosts.length,
shippingMatchRate,
printingDataRate,
// Profitability Analysis
profitableOrders: orderCosts.filter(order => order.grossProfit > 0).length,
breakEvenOrders: orderCosts.filter(order => order.grossProfit === 0).length,
lossOrders: orderCosts.filter(order => order.grossProfit < 0).length
};
}
private parseAmount(amountStr: string): number {
if (!amountStr || amountStr === '--') return 0;
// Remove currency symbols and parse
const cleaned = amountStr.replace(/[AU$\s-]/g, '');
return parseFloat(cleaned) || 0;
}
private parseDate(dateStr: string): string {
// Convert "29 July, 2025" to "2025-07-29"
const months: Record<string, string> = {
'January': '01', 'February': '02', 'March': '03', 'April': '04',
'May': '05', 'June': '06', 'July': '07', 'August': '08',
'September': '09', 'October': '10', 'November': '11', 'December': '12'
};
const match = dateStr.match(/(\d+)\s+(\w+),\s+(\d+)/);
if (match) {
const [, day, month, year] = match;
const monthNum = months[month] || '01';
return `${year}-${monthNum}-${day.padStart(2, '0')}`;
}
return dateStr;
}
private parseAustraliaPostDate(dateStr: string): string {
// Convert "17/03/2026" to "2026-03-17"
const parts = dateStr.split('/');
if (parts.length === 3) {
const [day, month, year] = parts;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return dateStr;
}
private cleanTrackingNumber(trackingStr: string): string {
// Remove Excel formula formatting like ="99720112046401000830902"
return trackingStr.replace(/^="([^"]*)"$/, '$1');
}
}
// Export singleton instance
export const csvImportService = new CSVImportService();

View file

@ -0,0 +1,91 @@
// Data management utilities for testing and data clearing
export class DataManager {
/**
* Clear all stored application data
*/
static clearAllData(): void {
const keys = [
'orders',
'products',
'customers',
'expenses',
// Add any other localStorage keys used by the app
];
keys.forEach(key => {
localStorage.removeItem(key);
});
// Also clear any other potential storage
localStorage.removeItem('persist:root'); // In case Redux persist is used differently
console.log('All application data cleared from localStorage');
}
/**
* Get storage usage summary
*/
static getStorageSummary(): {
orders: number;
products: number;
customers: number;
expenses: number;
totalItems: number;
} {
const getCount = (key: string): number => {
try {
const data = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(data) ? data.length : 0;
} catch {
return 0;
}
};
const orders = getCount('orders');
const products = getCount('products');
const customers = getCount('customers');
const expenses = getCount('expenses');
return {
orders,
products,
customers,
expenses,
totalItems: orders + products + customers + expenses
};
}
/**
* Export all data as JSON (for backup before clearing)
*/
static exportAllData(): string {
const data = {
orders: JSON.parse(localStorage.getItem('orders') || '[]'),
products: JSON.parse(localStorage.getItem('products') || '[]'),
customers: JSON.parse(localStorage.getItem('customers') || '[]'),
expenses: JSON.parse(localStorage.getItem('expenses') || '[]'),
exportDate: new Date().toISOString()
};
return JSON.stringify(data, null, 2);
}
/**
* Clear data and download backup
*/
static clearWithBackup(): void {
// Create backup
const backup = this.exportAllData();
const blob = new Blob([backup], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `etsy-tracker-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
window.URL.revokeObjectURL(url);
// Clear data
this.clearAllData();
}
}

View file

@ -0,0 +1,25 @@
/**
* Utility function for formatting dates in Australian format (DD/MM/YYYY)
*/
export const formatAustralianDate = (dateString: string | Date) => {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
return date.toLocaleDateString('en-AU', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
/**
* Utility function for formatting dates in Australian format with time (DD/MM/YYYY HH:MM)
*/
export const formatAustralianDateTime = (dateString: string | Date) => {
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
return date.toLocaleDateString('en-AU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};

View file

@ -0,0 +1,76 @@
// Utility functions for order calculations including printing costs
export interface OrderItem {
title: string;
quantity: number;
price: number;
printingCost?: number;
costOfGoods?: number;
}
export interface Product {
_id: string;
title: string;
price: number;
costOfGoods: number;
printingCost: number;
sku?: string;
}
// Calculate total printing cost for an order
export const calculateOrderPrintingCost = (items: OrderItem[]): number => {
return items.reduce((total, item) => {
const printingCost = item.printingCost || 0;
return total + (printingCost * item.quantity);
}, 0);
};
// Calculate total profit for an order (net revenue - costs - printing)
export const calculateOrderProfit = (items: OrderItem[], orderTotal: number = 0): number => {
// If orderTotal is provided (from Etsy CSV), use it as net revenue (fees already deducted)
// Otherwise, calculate revenue from item prices
const revenue = orderTotal > 0 ? orderTotal : items.reduce((total, item) => total + (item.price * item.quantity), 0);
// Calculate total costs (printing + cost of goods)
const costs = items.reduce((total, item) => {
const itemCosts = (item.costOfGoods || 0) + (item.printingCost || 0);
return total + (itemCosts * item.quantity);
}, 0);
// For Etsy orders, orderTotal already has fees deducted, so we don't subtract fees again
return revenue - costs;
};
// Match order items with products to add cost information
export const enrichOrderItemsWithCosts = (
items: OrderItem[],
products: Product[]
): OrderItem[] => {
return items.map(item => {
// Try to find matching product by title (could be improved with SKU matching)
const matchingProduct = products.find(product =>
product.title.toLowerCase().includes(item.title.toLowerCase()) ||
item.title.toLowerCase().includes(product.title.toLowerCase())
);
if (matchingProduct) {
return {
...item,
printingCost: matchingProduct.printingCost,
costOfGoods: matchingProduct.costOfGoods
};
}
return item;
});
};
// Calculate total printing costs for all orders
export const calculateTotalPrintingCosts = (orders: any[]): number => {
return orders.reduce((total, order) => {
if (order.items) {
return total + calculateOrderPrintingCost(order.items);
}
return total;
}, 0);
};

View file

@ -0,0 +1,263 @@
// PDF Parser for extracting item details from Etsy packing slips
import * as pdfjsLib from 'pdfjs-dist';
// Set up PDF.js worker - use local worker file served by Vite
if (typeof window !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.js';
}
export interface ParsedItem {
title: string;
quantity: number;
sku?: string;
variation?: string;
}
export interface ParsedPackingSlip {
orderNumber: string;
customerName: string;
customerEmail?: string;
items: ParsedItem[]; // Only item details for printing cost calculation
orderDate?: string;
shippingAddress?: {
street: string;
city: string;
state: string;
zip: string;
country: string;
};
// Note: Sales price and postage costs are tracked from Etsy CSV and Australia Post CSV
// This packing slip data is only used to calculate printing costs
}
export class PDFPackingSlipParser {
async parsePackingSlip(file: File): Promise<ParsedPackingSlip> {
console.log('Starting PDF parsing for file:', file.name, 'Size:', file.size, 'Type:', file.type);
try {
const arrayBuffer = await file.arrayBuffer();
console.log('File read as ArrayBuffer, size:', arrayBuffer.byteLength);
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
console.log('PDF loaded successfully, pages:', pdf.numPages);
let fullText = '';
// Extract text from all pages
for (let i = 1; i <= pdf.numPages; i++) {
try {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join(' ');
fullText += pageText + '\n';
console.log(`Page ${i} text length:`, pageText.length);
} catch (pageError) {
console.warn(`Error reading page ${i}:`, pageError);
}
}
console.log('Total extracted text length:', fullText.length);
console.log('First 500 characters:', fullText.substring(0, 500));
const result = this.parsePackingSlipText(fullText);
console.log('Parsing result:', result);
return result;
} catch (error) {
console.error('Detailed PDF parsing error:', error);
if (error instanceof Error) {
throw new Error(`PDF parsing failed: ${error.message}`);
} else {
throw new Error('PDF parsing failed: Unknown error');
}
}
}
private parsePackingSlipText(text: string): ParsedPackingSlip {
console.log('Raw PDF text:', text); // For debugging
// Initialize result
const result: ParsedPackingSlip = {
orderNumber: '',
customerName: '',
items: []
};
// Extract order number (various patterns)
const orderNumberPatterns = [
/Order #(\d+)/i,
/Order Number:?\s*(\d+)/i,
/Receipt #(\d+)/i,
/#(\d{10,})/i // Long numeric ID
];
for (const pattern of orderNumberPatterns) {
const match = text.match(pattern);
if (match) {
result.orderNumber = match[1];
break;
}
}
// Extract customer name
const namePatterns = [
/Deliver to\s+([A-Z][a-zA-Z\s]+?)(?:\s+U\s+\d+|\s+\d+)/i, // Etsy format: "Deliver to Jennifer Tovo U 716"
/Buyer\s+([A-Z][a-zA-Z\s]+?)\s+\(/i, // Etsy format: "Buyer Jennifer Tovo ("
/Ship to:?\s*([A-Z][a-zA-Z\s]+)/i,
/Customer:?\s*([A-Z][a-zA-Z\s]+)/i,
/Bill to:?\s*([A-Z][a-zA-Z\s]+)/i
];
for (const pattern of namePatterns) {
const match = text.match(pattern);
if (match) {
result.customerName = match[1].trim();
break;
}
}
// Extract email
const emailMatch = text.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
if (emailMatch) {
result.customerEmail = emailMatch[1];
}
// Extract order date
const datePatterns = [
/Order [Dd]ate:?\s*(\d{1,2} [A-Z][a-z]{2}, \d{4})/i, // "21 Jul, 2025"
/Order [Dd]ate:?\s*([A-Z][a-z]+ \d{1,2}, \d{4})/i, // "July 21, 2025"
/Date:?\s*(\d{1,2}\/\d{1,2}\/\d{4})/i, // "21/07/2025"
/(\d{4}-\d{2}-\d{2})/ // "2025-07-21"
];
for (const pattern of datePatterns) {
const match = text.match(pattern);
if (match) {
result.orderDate = match[1];
break;
}
}
// Extract items - this is the complex part
result.items = this.extractItems(text);
// Extract shipping address
result.shippingAddress = this.extractShippingAddress(text);
return result;
}
private extractItems(text: string): ParsedItem[] {
const items: ParsedItem[] = [];
console.log('Extracting items from text:', text);
// For Etsy packing slips, look for the pattern after "items" and before "Item total"
const itemsSection = text.match(/(\d+)\s+items?\s+(.*?)Item total/is);
if (itemsSection) {
const itemsText = itemsSection[2];
console.log('Items section:', itemsText);
// Look for price patterns first to identify item boundaries
const priceMatches = [...itemsText.matchAll(/(\d+)\s+x\s+AU\$(\d+\.?\d*)/g)];
console.log('Price matches found:', priceMatches.map(m => m[0]));
if (priceMatches.length > 0) {
// Split the text by price patterns to get item descriptions
const textParts = itemsText.split(/\d+\s+x\s+AU\$\d+\.?\d*/);
for (let i = 0; i < priceMatches.length; i++) {
const priceMatch = priceMatches[i];
const quantity = parseInt(priceMatch[1]);
// Note: We ignore the price from packing slip as it's tracked from Etsy CSV
// Get the item text before this price
let itemText = '';
if (i < textParts.length - 1) {
itemText = textParts[i];
}
// If this is not the first item, we need to get text between previous price and this one
if (i > 0 && i < textParts.length) {
itemText = textParts[i];
} else if (i === 0) {
itemText = textParts[0] || '';
}
// Clean up the item title but preserve color/size info
let title = itemText
.trim()
.replace(/^\s*\d+\s+x\s+AU\$\d+\.?\d*/, '') // Remove any price at start
.replace(/\s+/g, ' ') // Normalize spaces
.trim();
// If title is empty or too short, try to extract from the full text
if (title.length < 3) {
// Find the text immediately before this price match
const beforePrice = itemsText.substring(0, priceMatch.index);
const lines = beforePrice.split(/\s{2,}|\n/); // Split on multiple spaces or newlines
title = lines[lines.length - 1]?.trim() || `Item ${i + 1}`;
}
// Clean up title but keep color and size information
title = title
.replace(/^.*?(Jewellery|Modern|Custom|Photo|Wedding|Business)/, '$1') // Keep main product words
.trim();
if (title && quantity > 0) {
console.log('Extracted item:', { title, quantity });
items.push({ title, quantity });
}
}
}
}
// Enhanced fallback: Look for the specific Etsy format in the full text
if (items.length === 0) {
console.log('No items found with section method, trying direct pattern matching...');
// Pattern to find complete item lines in the original text
// Look for the specific format: "Item Name Colour: ... Size: ... 1 x AU$15.00"
const etsyPattern = /((?:Jewellery|Modern|Custom|Photo|Wedding|Business|[\w\s\-]+?)(?:\s+Colour:[^1-9]*?|\s+Size:[^1-9]*?)*)\s*(\d+)\s+x\s+AU\$(\d+\.?\d*)/gi;
let match;
while ((match = etsyPattern.exec(text)) !== null) {
const title = match[1]
.replace(/\s+/g, ' ')
.trim();
const quantity = parseInt(match[2]);
// Note: We ignore the price from packing slip as it's tracked from Etsy CSV
if (title && quantity > 0) {
console.log('Direct pattern found item:', { title, quantity });
items.push({ title, quantity });
}
}
}
console.log('Final extracted items:', items);
return items;
}
private extractShippingAddress(text: string): any {
// Basic address extraction - can be enhanced
const addressMatch = text.match(/Ship to:?\s*([A-Z][a-zA-Z\s]+)\s+(.+?)\s+([A-Z]{2}\s+\d{5})/i);
if (addressMatch) {
return {
street: addressMatch[2],
city: 'Unknown', // Would need more sophisticated parsing
state: 'Unknown',
zip: 'Unknown',
country: 'US'
};
}
return undefined;
}
}
// Export singleton instance
export const pdfParser = new PDFPackingSlipParser();

View file

@ -0,0 +1,184 @@
// Utility functions for matching products between orders and inventory
export interface ProductMatch {
orderItem: {
title: string;
quantity: number;
price?: number;
};
matchedProduct?: {
_id: string;
title: string;
printingCost: number;
costOfGoods: number;
size?: string;
};
confidence: number; // 0-1, how confident we are in the match
}
export interface MatchingResult {
matches: ProductMatch[];
missingProducts: {
title: string;
quantity: number;
price?: number;
size?: string;
}[];
}
/**
* Match order items against existing products in the database
* Considers size as a factor but ignores color variations
*/
export const matchOrderItemsToProducts = (
orderItems: any[],
products: any[]
): MatchingResult => {
const matches: ProductMatch[] = [];
const missingProducts: any[] = [];
for (const item of orderItems) {
let bestMatch: any = null;
let bestConfidence = 0;
// Extract size from item title if present
const itemSizeMatch = item.title.match(/Size:\s*([^,\s]+(?:\s+[^,\s]+)*)/i);
const itemSize = itemSizeMatch ? itemSizeMatch[1].trim().toLowerCase() : '';
// Clean item title for comparison (remove color but keep size info)
const cleanItemTitle = item.title
.replace(/Colour:\s*[^,\s]+(?:\s+[^,\s]+)*/i, '') // Remove color
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
console.log('Matching item:', cleanItemTitle, 'Size:', itemSize);
for (const product of products) {
const cleanProductTitle = product.title
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const productSize = product.size ? product.size.toLowerCase() : '';
// Calculate title similarity (basic word matching)
const titleSimilarity = calculateTitleSimilarity(cleanItemTitle, cleanProductTitle);
// Size matching bonus
let sizeSimilarity = 0;
if (itemSize && productSize) {
sizeSimilarity = itemSize === productSize ? 1 : 0;
} else if (!itemSize && !productSize) {
sizeSimilarity = 0.5; // Neutral if neither has size
}
// Combined confidence score
const confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2);
console.log(`Product: ${cleanProductTitle}, Title sim: ${titleSimilarity}, Size sim: ${sizeSimilarity}, Confidence: ${confidence}`);
if (confidence > bestConfidence && confidence >= 0.5) { // Fixed: >= instead of > for exact threshold matches
bestMatch = product;
bestConfidence = confidence;
}
}
if (bestMatch) {
matches.push({
orderItem: {
title: item.title,
quantity: item.quantity,
price: item.price
},
matchedProduct: {
_id: bestMatch._id,
title: bestMatch.title,
printingCost: bestMatch.printingCost || 0,
costOfGoods: bestMatch.costOfGoods || 0,
size: bestMatch.size
},
confidence: bestConfidence
});
console.log(`✅ Matched "${item.title}" to "${bestMatch.title}" (${(bestConfidence * 100).toFixed(1)}%)`);
} else {
missingProducts.push({
title: item.title,
quantity: item.quantity,
...(item.price !== undefined && { price: item.price }),
size: itemSize
});
console.log(`❌ No match found for "${item.title}"`);
}
}
return { matches, missingProducts };
};
/**
* Calculate similarity between two product titles
* Uses word matching and common substring analysis
*/
const calculateTitleSimilarity = (title1: string, title2: string): number => {
// Split into words and filter out common stop words
const stopWords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', '-', ''];
const words1 = title1.split(/\s+/).filter(word =>
word.length > 2 && !stopWords.includes(word)
);
const words2 = title2.split(/\s+/).filter(word =>
word.length > 2 && !stopWords.includes(word)
);
if (words1.length === 0 || words2.length === 0) return 0;
// Count matching words
let matchingWords = 0;
for (const word1 of words1) {
for (const word2 of words2) {
if (word1 === word2 ||
word1.includes(word2) ||
word2.includes(word1) ||
levenshteinDistance(word1, word2) <= Math.max(1, Math.min(word1.length, word2.length) * 0.2)) {
matchingWords++;
break;
}
}
}
// Calculate similarity as ratio of matching words to total unique words
const totalUniqueWords = Math.max(words1.length, words2.length);
return matchingWords / totalUniqueWords;
};
/**
* Calculate Levenshtein distance between two strings
* Used for fuzzy string matching
*/
const levenshteinDistance = (str1: string, str2: string): number => {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[str2.length][str1.length];
};

View file

@ -0,0 +1,473 @@
import { Order } from '../store/slices/orderSlice';
import { Product } from '../store/slices/productSlice';
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations';
export interface ProfitMetrics {
totalRevenue: number;
totalPrintingCosts: number;
totalProfit: number;
averageMargin: number;
orderCount: number;
profitableOrders: number;
averageOrderValue: number;
averageCost: number;
}
export interface ProductProfitability {
name: string;
revenue: number;
cost: number;
profit: number;
margin: number;
orders: number;
quantity: number;
averageOrderValue: number;
profitPerUnit: number;
}
export interface MonthlyProfit {
month: string;
revenue: number;
costs: number;
profit: number;
margin: number;
orderCount: number;
}
export interface OrderProfitAnalysis {
orderId: string;
orderNumber: string;
date: string;
customerName: string;
revenue: number;
printingCosts: number;
profit: number;
margin: number;
items: OrderItemAnalysis[];
profitCategory: 'excellent' | 'good' | 'average' | 'poor' | 'loss';
}
export interface OrderItemAnalysis {
title: string;
quantity: number;
price: number;
printingCost: number;
itemRevenue: number;
itemCost: number;
itemProfit: number;
itemMargin: number;
}
export interface DateRangeOption {
value: string;
label: string;
type: 'preset' | 'month' | 'quarter' | 'year' | 'custom';
}
export class ProfitAnalysisService {
/**
* Calculate comprehensive profit metrics from orders
*/
static calculateProfitMetrics(orders: Order[], products: Product[]): ProfitMetrics {
if (!orders || orders.length === 0) {
return {
totalRevenue: 0,
totalPrintingCosts: 0,
totalProfit: 0,
averageMargin: 0,
orderCount: 0,
profitableOrders: 0,
averageOrderValue: 0,
averageCost: 0
};
}
let totalRevenue = 0;
let totalPrintingCosts = 0;
let totalProfit = 0;
let profitableOrderCount = 0;
orders.forEach(order => {
const revenue = order.total || 0;
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
const printingCost = calculateOrderPrintingCost(enrichedItems);
const profit = calculateOrderProfit(enrichedItems, revenue);
totalRevenue += revenue;
totalPrintingCosts += printingCost;
totalProfit += profit;
if (profit > 0) profitableOrderCount++;
});
const averageMargin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
const averageOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
const averageCost = orders.length > 0 ? totalPrintingCosts / orders.length : 0;
return {
totalRevenue,
totalPrintingCosts,
totalProfit,
averageMargin,
orderCount: orders.length,
profitableOrders: profitableOrderCount,
averageOrderValue,
averageCost
};
}
/**
* Analyze profitability by product
*/
static analyzeProductProfitability(orders: Order[], products: Product[]): ProductProfitability[] {
if (!orders || !products) return [];
const productStats = new Map<string, ProductProfitability>();
orders.forEach(order => {
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
enrichedItems.forEach(item => {
const revenue = (item.price || 0) * (item.quantity || 1);
const cost = (item.printingCost || 0) * (item.quantity || 1);
const profit = revenue - cost;
if (!productStats.has(item.title)) {
productStats.set(item.title, {
name: item.title,
revenue: 0,
cost: 0,
profit: 0,
margin: 0,
orders: 0,
quantity: 0,
averageOrderValue: 0,
profitPerUnit: 0
});
}
const stats = productStats.get(item.title)!;
stats.revenue += revenue;
stats.cost += cost;
stats.profit += profit;
stats.orders += 1;
stats.quantity += item.quantity || 1;
});
});
// Calculate derived metrics
productStats.forEach(stats => {
stats.margin = stats.revenue > 0 ? (stats.profit / stats.revenue) * 100 : 0;
stats.averageOrderValue = stats.orders > 0 ? stats.revenue / stats.orders : 0;
stats.profitPerUnit = stats.quantity > 0 ? stats.profit / stats.quantity : 0;
});
return Array.from(productStats.values())
.sort((a, b) => b.profit - a.profit);
}
/**
* Calculate monthly profit trends
*/
static calculateMonthlyTrends(orders: Order[], products: Product[]): MonthlyProfit[] {
if (!orders || orders.length === 0) return [];
const monthlyData = new Map<string, MonthlyProfit>();
orders.forEach(order => {
const orderDate = new Date(order.dateOrdered);
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
const monthName = orderDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
const revenue = order.total || 0;
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
const costs = calculateOrderPrintingCost(enrichedItems);
const profit = revenue - costs;
if (!monthlyData.has(monthKey)) {
monthlyData.set(monthKey, {
month: monthName,
revenue: 0,
costs: 0,
profit: 0,
margin: 0,
orderCount: 0
});
}
const monthStats = monthlyData.get(monthKey)!;
monthStats.revenue += revenue;
monthStats.costs += costs;
monthStats.profit += profit;
monthStats.orderCount += 1;
});
// Calculate margins and sort by date
const results = Array.from(monthlyData.entries())
.map(([key, data]) => {
data.margin = data.revenue > 0 ? (data.profit / data.revenue) * 100 : 0;
return { key, ...data };
})
.sort((a, b) => a.key.localeCompare(b.key))
.map(({ key, ...data }) => data);
return results;
}
/**
* Filter orders by date range
*/
static filterOrdersByDateRange(orders: Order[], dateRange: string): Order[] {
if (dateRange === 'all' || !orders) return orders;
const now = new Date();
const filteredOrders = orders.filter(order => {
const orderDate = new Date(order.dateOrdered);
// Handle specific month format (e.g., "2025-07" for July 2025)
if (dateRange.match(/^\d{4}-\d{2}$/)) {
const [year, month] = dateRange.split('-');
return orderDate.getFullYear() === parseInt(year) &&
orderDate.getMonth() === parseInt(month) - 1;
}
// Handle quarter format (e.g., "2025-Q1")
if (dateRange.match(/^\d{4}-Q[1-4]$/)) {
const [year, quarter] = dateRange.split('-Q');
const targetYear = parseInt(year);
const targetQuarter = parseInt(quarter);
const orderYear = orderDate.getFullYear();
const orderQuarter = Math.floor(orderDate.getMonth() / 3) + 1;
return orderYear === targetYear && orderQuarter === targetQuarter;
}
// Handle year format (e.g., "2025")
if (dateRange.match(/^\d{4}$/)) {
return orderDate.getFullYear() === parseInt(dateRange);
}
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
if (dateRange.includes('_')) {
const [startDate, endDate] = dateRange.split('_');
const start = new Date(startDate);
const end = new Date(endDate);
return orderDate >= start && orderDate <= end;
}
// Handle legacy preset ranges
const timeDiff = now.getTime() - orderDate.getTime();
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
switch (dateRange) {
case 'week':
return daysDiff >= 0 && daysDiff <= 7;
case 'month':
return daysDiff >= 0 && daysDiff <= 30;
case 'quarter':
return daysDiff >= 0 && daysDiff <= 90;
case 'year':
return daysDiff >= 0 && daysDiff <= 365;
default:
return true;
}
});
return filteredOrders;
}
/**
* Generate available date range options based on order data
*/
static generateDateRangeOptions(orders: Order[]): DateRangeOption[] {
if (!orders || orders.length === 0) {
return [{ value: 'all', label: 'All Time', type: 'preset' }];
}
const options: DateRangeOption[] = [
{ value: 'all', label: 'All Time', type: 'preset' },
{ value: 'week', label: 'Last 7 Days', type: 'preset' },
{ value: 'month', label: 'Last 30 Days', type: 'preset' },
{ value: 'quarter', label: 'Last 90 Days', type: 'preset' },
{ value: 'year', label: 'Last 365 Days', type: 'preset' }
];
// Get unique months from orders
const monthsSet = new Set<string>();
const quartersSet = new Set<string>();
const yearsSet = new Set<string>();
orders.forEach(order => {
const date = new Date(order.dateOrdered);
const year = date.getFullYear();
const month = date.getMonth();
const quarter = Math.floor(month / 3) + 1;
// Add specific months
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
monthsSet.add(monthKey);
// Add quarters
const quarterKey = `${year}-Q${quarter}`;
quartersSet.add(quarterKey);
// Add years
yearsSet.add(year.toString());
});
// Convert to options and sort
const monthOptions: DateRangeOption[] = Array.from(monthsSet)
.sort((a, b) => b.localeCompare(a)) // Newest first
.slice(0, 24) // Limit to last 24 months
.map(monthKey => {
const [year, month] = monthKey.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
const label = date.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
return { value: monthKey, label, type: 'month' as const };
});
const quarterOptions: DateRangeOption[] = Array.from(quartersSet)
.sort((a, b) => b.localeCompare(a)) // Newest first
.slice(0, 8) // Limit to last 8 quarters
.map(quarterKey => ({
value: quarterKey,
label: quarterKey.replace('-', ' '),
type: 'quarter' as const
}));
const yearOptions: DateRangeOption[] = Array.from(yearsSet)
.sort((a, b) => parseInt(b) - parseInt(a)) // Newest first
.slice(0, 5) // Limit to last 5 years
.map(year => ({
value: year,
label: year,
type: 'year' as const
}));
// Combine all options with separators
return [
...options,
...monthOptions,
...quarterOptions,
...yearOptions
];
}
/**
* Get date range label for display
*/
static getDateRangeLabel(dateRange: string, orders: Order[]): string {
const options = this.generateDateRangeOptions(orders);
const option = options.find(opt => opt.value === dateRange);
return option ? option.label : dateRange;
}
/**
* Get top/bottom performing products
*/
static getTopPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] {
return this.analyzeProductProfitability(orders, products).slice(0, count);
}
static getWorstPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] {
return this.analyzeProductProfitability(orders, products)
.sort((a, b) => a.profit - b.profit)
.slice(0, count);
}
/**
* Calculate profit margin categories
*/
static categorizeByMargin(products: ProductProfitability[]): {
excellent: ProductProfitability[]; // >50%
good: ProductProfitability[]; // 30-50%
average: ProductProfitability[]; // 15-30%
poor: ProductProfitability[]; // 0-15%
loss: ProductProfitability[]; // <0%
} {
return {
excellent: products.filter(p => p.margin > 50),
good: products.filter(p => p.margin > 30 && p.margin <= 50),
average: products.filter(p => p.margin > 15 && p.margin <= 30),
poor: products.filter(p => p.margin > 0 && p.margin <= 15),
loss: products.filter(p => p.margin <= 0)
};
}
/**
* Analyze individual order profitability with item breakdown
*/
static analyzeOrderProfitability(orders: Order[], products: Product[]): OrderProfitAnalysis[] {
if (!orders || !products) return [];
return orders.map(order => {
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
const revenue = order.total || 0;
const printingCosts = calculateOrderPrintingCost(enrichedItems);
const profit = calculateOrderProfit(enrichedItems, revenue);
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
// Analyze each item
const itemAnalyses: OrderItemAnalysis[] = enrichedItems.map(item => {
const itemRevenue = (item.price || 0) * (item.quantity || 1);
const itemCost = (item.printingCost || 0) * (item.quantity || 1);
const itemProfit = itemRevenue - itemCost;
const itemMargin = itemRevenue > 0 ? (itemProfit / itemRevenue) * 100 : 0;
return {
title: item.title,
quantity: item.quantity || 1,
price: item.price || 0,
printingCost: item.printingCost || 0,
itemRevenue,
itemCost,
itemProfit,
itemMargin
};
});
// Categorize profit performance
let profitCategory: OrderProfitAnalysis['profitCategory'];
if (margin > 50) profitCategory = 'excellent';
else if (margin > 30) profitCategory = 'good';
else if (margin > 15) profitCategory = 'average';
else if (margin > 0) profitCategory = 'poor';
else profitCategory = 'loss';
return {
orderId: order._id,
orderNumber: order.orderNumber,
date: order.dateOrdered,
customerName: order.customer?.name || 'Unknown Customer',
revenue,
printingCosts,
profit,
margin,
items: itemAnalyses,
profitCategory
};
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
/**
* Get orders by profit category
*/
static getOrdersByProfitCategory(orders: Order[], products: Product[]): {
excellent: OrderProfitAnalysis[];
good: OrderProfitAnalysis[];
average: OrderProfitAnalysis[];
poor: OrderProfitAnalysis[];
loss: OrderProfitAnalysis[];
} {
const analyses = this.analyzeOrderProfitability(orders, products);
return {
excellent: analyses.filter(a => a.profitCategory === 'excellent'),
good: analyses.filter(a => a.profitCategory === 'good'),
average: analyses.filter(a => a.profitCategory === 'average'),
poor: analyses.filter(a => a.profitCategory === 'poor'),
loss: analyses.filter(a => a.profitCategory === 'loss')
};
}
}
export default ProfitAnalysisService;

View file

@ -0,0 +1,43 @@
// Test script to parse the actual packing slip PDF
import { pdfParser } from './pdfParser';
async function testActualPDF() {
try {
// Simulate a File object for the PDF
const response = await fetch('/3748364725.pdf');
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const file = new File([blob], '3748364725.pdf', { type: 'application/pdf' });
console.log('Testing actual packing slip PDF...');
console.log('File size:', file.size, 'bytes');
const result = await pdfParser.parsePackingSlip(file);
console.log('Parse Result:', JSON.stringify(result, null, 2));
if (result.items && result.items.length > 0) {
console.log('\n=== PARSED ITEMS ===');
console.log('Order Number:', result.orderNumber);
console.log('Items:', result.items);
result.items.forEach((item, index: number) => {
console.log(`Item ${index + 1}: ${item.title} (Qty: ${item.quantity})`);
});
} else {
console.log('No items found in PDF');
}
} catch (error) {
console.error('Error testing PDF:', error);
}
}
// Export for manual testing
export { testActualPDF };
// If running directly (not imported), execute the test
if (typeof window !== 'undefined') {
// Browser environment
(window as any).testActualPDF = testActualPDF;
console.log('Test function available as window.testActualPDF()');
}

View file

@ -0,0 +1,54 @@
// Quick test for date parsing from packing slip text
export function testDateParsing() {
// Sample text from actual packing slip
const sampleText = `
Packing slip for order #3748364725
Order date 21 Jul, 2025
Ship to:
David L
`;
// Test the regex pattern (same as in pdfParser.ts)
const datePattern = /Order [Dd]ate:?\s*(\d{1,2} [A-Z][a-z]{2}, \d{4})/i;
const dateMatch = sampleText.match(datePattern);
console.log('=== TESTING DATE EXTRACTION ===');
console.log('Sample text contains:', sampleText.trim());
console.log('Date pattern:', datePattern.toString());
console.log('Match found:', dateMatch);
if (dateMatch && dateMatch[1]) {
const extractedDate = dateMatch[1];
console.log('Extracted date string:', extractedDate);
// Test parsing to Date object
try {
const parsedDate = new Date(extractedDate);
console.log('Parsed Date object:', parsedDate);
console.log('Is valid date:', !isNaN(parsedDate.getTime()));
console.log('ISO string:', parsedDate.toISOString());
// Test today's date for comparison
const today = new Date();
console.log('Today for comparison:', today.toISOString());
console.log('Are dates different?', parsedDate.toDateString() !== today.toDateString());
return {
success: true,
extractedDate,
parsedDate: parsedDate.toISOString(),
isValidDate: !isNaN(parsedDate.getTime()),
isDifferentFromToday: parsedDate.toDateString() !== today.toDateString()
};
} catch (error) {
console.error('Date parsing error:', error);
return { success: false, error };
}
} else {
console.log('No date match found - this is the problem!');
return { success: false, error: 'No date match found' };
}
}
// Export for use in components
export const dateTestResults = testDateParsing();

View file

@ -0,0 +1,18 @@
// Simple test component for PDF parsing
import { pdfParser } from '../utils/pdfParser';
export const testPDF = async (file: File) => {
console.log('=== PDF Test Debug ===');
console.log('File name:', file.name);
console.log('File size:', file.size);
console.log('File type:', file.type);
try {
const result = await pdfParser.parsePackingSlip(file);
console.log('Parse result:', result);
return result;
} catch (error) {
console.error('Parse error:', error);
throw error;
}
};

11
client/tailwind.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

31
client/tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

23
client/vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
},
},
})

373
package-lock.json generated Normal file
View file

@ -0,0 +1,373 @@
{
"name": "etsy-business-tracker",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "etsy-business-tracker",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "etsy-business-tracker",
"version": "1.0.0",
"description": "A comprehensive Etsy business tracking web application",
"main": "index.js",
"scripts": {
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
"client:dev": "cd client && npm run dev",
"server:dev": "cd server && npm run dev",
"client:build": "cd client && npm run build",
"server:build": "cd server && npm run build",
"client:install": "cd client && npm install",
"server:install": "cd server && npm install",
"install:all": "npm install && npm run client:install && npm run server:install",
"build": "npm run client:build && npm run server:build",
"start": "cd server && npm start",
"test": "npm run client:test && npm run server:test",
"client:test": "cd client && npm test",
"server:test": "cd server && npm test"
},
"keywords": ["etsy", "business", "tracking", "analytics", "inventory", "orders"],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"concurrently": "^8.2.2"
}
}

13
server/.env.example Normal file
View file

@ -0,0 +1,13 @@
# Environment Variables
NODE_ENV=development
PORT=3001
CLIENT_URL=http://localhost:3000
# Database
MONGODB_URI=mongodb://localhost:27017/etsy-tracker
# JWT Secret (change in production)
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Session Secret (change in production)
SESSION_SECRET=your-super-secret-session-key-change-this-in-production

6280
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

46
server/package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "etsy-tracker-server",
"version": "1.0.0",
"description": "Backend API for Etsy Business Tracker",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": ["etsy", "tracking", "api", "express", "mongodb"],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"mongoose": "^8.0.1",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.5",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"joi": "^17.11.0",
"dotenv": "^16.3.1",
"morgan": "^1.10.0",
"compression": "^1.7.4",
"express-validator": "^7.0.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
"@types/compression": "^1.7.5",
"@types/node": "^20.9.1",
"typescript": "^5.2.2",
"tsx": "^4.4.0",
"@types/jest": "^29.5.8",
"jest": "^29.7.0",
"supertest": "^6.3.3",
"@types/supertest": "^2.0.16",
"ts-jest": "^29.1.1"
}
}

83
server/src/index.ts Normal file
View file

@ -0,0 +1,83 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import rateLimit from 'express-rate-limit';
// Import routes
import authRoutes from './routes/auth';
import productRoutes from './routes/products';
import orderRoutes from './routes/orders';
import customerRoutes from './routes/customers';
import expenseRoutes from './routes/expenses';
import analyticsRoutes from './routes/analytics';
// Load environment variables
dotenv.config();
const app = express();
const PORT = process.env.PORT || 8080;
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:3000',
credentials: true,
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later.',
});
app.use(limiter);
// Body parsing middleware
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Logging middleware
app.use(morgan('combined'));
// Database connection (temporarily disabled for development)
// mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/etsy-tracker')
// .then(() => console.log('Connected to MongoDB'))
// .catch((error) => console.error('MongoDB connection error:', error));
console.log('Running in development mode without MongoDB - using in-memory storage');
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/products', productRoutes);
app.use('/api/orders', orderRoutes);
app.use('/api/customers', customerRoutes);
app.use('/api/expenses', expenseRoutes);
app.use('/api/analytics', analyticsRoutes);
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ message: 'Etsy Tracker API is running', timestamp: new Date().toISOString() });
});
// Global error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({
message: 'Something went wrong!',
error: process.env.NODE_ENV === 'production' ? {} : err
});
});
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({ message: 'Route not found' });
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

View file

@ -0,0 +1,90 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IOrder extends Document {
orderNumber: string;
etsyOrderId?: string;
customerId: mongoose.Types.ObjectId;
items: {
productId: mongoose.Types.ObjectId;
sku: string;
title: string;
quantity: number;
price: number;
variant?: string;
}[];
subtotal: number;
shipping: number;
tax: number;
total: number;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
shippingAddress: {
name: string;
street1: string;
street2?: string;
city: string;
state: string;
zipCode: string;
country: string;
};
trackingNumber?: string;
shippingCarrier?: string;
notes?: string;
dateOrdered: Date;
dateShipped?: Date;
dateDelivered?: Date;
dateCreated: Date;
dateUpdated: Date;
}
const OrderSchema: Schema = new Schema({
orderNumber: { type: String, required: true, unique: true },
etsyOrderId: { type: String },
customerId: { type: Schema.Types.ObjectId, ref: 'Customer', required: true },
items: [{
productId: { type: Schema.Types.ObjectId, ref: 'Product', required: true },
sku: { type: String, required: true },
title: { type: String, required: true },
quantity: { type: Number, required: true, min: 1 },
price: { type: Number, required: true, min: 0 },
variant: { type: String }
}],
subtotal: { type: Number, required: true, min: 0 },
shipping: { type: Number, required: true, min: 0 },
tax: { type: Number, required: true, min: 0 },
total: { type: Number, required: true, min: 0 },
status: {
type: String,
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'],
default: 'pending'
},
paymentStatus: {
type: String,
enum: ['pending', 'paid', 'refunded', 'failed'],
default: 'pending'
},
shippingAddress: {
name: { type: String, required: true },
street1: { type: String, required: true },
street2: { type: String },
city: { type: String, required: true },
state: { type: String, required: true },
zipCode: { type: String, required: true },
country: { type: String, required: true }
},
trackingNumber: { type: String },
shippingCarrier: { type: String },
notes: { type: String },
dateOrdered: { type: Date, required: true },
dateShipped: { type: Date },
dateDelivered: { type: Date },
dateCreated: { type: Date, default: Date.now },
dateUpdated: { type: Date, default: Date.now }
});
OrderSchema.pre('save', function(next) {
this.dateUpdated = new Date();
next();
});
export default mongoose.model<IOrder>('Order', OrderSchema);

View file

@ -0,0 +1,75 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IProduct extends Document {
title: string;
description: string;
price: number;
costOfGoods: number;
sku: string;
category: string;
tags: string[];
images: string[];
variants: {
name: string;
options: string[];
price?: number;
sku?: string;
}[];
inventory: {
quantity: number;
lowStockAlert: number;
location?: string;
};
dimensions: {
length?: number;
width?: number;
height?: number;
weight?: number;
};
materials: string[];
isActive: boolean;
etsyListingId?: string;
dateCreated: Date;
dateUpdated: Date;
}
const ProductSchema: Schema = new Schema({
title: { type: String, required: true, trim: true },
description: { type: String, required: true },
price: { type: Number, required: true, min: 0 },
costOfGoods: { type: Number, required: true, min: 0 },
sku: { type: String, required: true, unique: true, trim: true },
category: { type: String, required: true },
tags: [{ type: String, trim: true }],
images: [{ type: String }],
variants: [{
name: { type: String, required: true },
options: [{ type: String, required: true }],
price: { type: Number, min: 0 },
sku: { type: String }
}],
inventory: {
quantity: { type: Number, required: true, min: 0 },
lowStockAlert: { type: Number, default: 5 },
location: { type: String }
},
dimensions: {
length: { type: Number, min: 0 },
width: { type: Number, min: 0 },
height: { type: Number, min: 0 },
weight: { type: Number, min: 0 }
},
materials: [{ type: String }],
isActive: { type: Boolean, default: true },
etsyListingId: { type: String },
dateCreated: { type: Date, default: Date.now },
dateUpdated: { type: Date, default: Date.now }
});
// Update dateUpdated on save
ProductSchema.pre('save', function(next) {
this.dateUpdated = new Date();
next();
});
export default mongoose.model<IProduct>('Product', ProductSchema);

View file

@ -0,0 +1,35 @@
import { Router } from 'express';
const router = Router();
// GET /api/analytics/dashboard
router.get('/dashboard', (req, res) => {
res.json({
message: 'Dashboard analytics endpoint',
data: {
totalRevenue: 0,
totalOrders: 0,
totalProducts: 0,
totalCustomers: 0,
recentOrders: [],
salesChart: [],
}
});
});
// GET /api/analytics/sales
router.get('/sales', (req, res) => {
res.json({ message: 'Sales analytics endpoint - coming soon!' });
});
// GET /api/analytics/products
router.get('/products', (req, res) => {
res.json({ message: 'Product analytics endpoint - coming soon!' });
});
// GET /api/analytics/customers
router.get('/customers', (req, res) => {
res.json({ message: 'Customer analytics endpoint - coming soon!' });
});
export default router;

20
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,20 @@
import { Router } from 'express';
const router = Router();
// POST /api/auth/login
router.post('/login', (req, res) => {
res.json({ message: 'Login endpoint - coming soon!' });
});
// POST /api/auth/register
router.post('/register', (req, res) => {
res.json({ message: 'Register endpoint - coming soon!' });
});
// POST /api/auth/logout
router.post('/logout', (req, res) => {
res.json({ message: 'Logout endpoint - coming soon!' });
});
export default router;

View file

@ -0,0 +1,25 @@
import { Router } from 'express';
const router = Router();
// GET /api/customers
router.get('/', (req, res) => {
res.json({ message: 'Get customers endpoint - coming soon!', customers: [] });
});
// POST /api/customers
router.post('/', (req, res) => {
res.json({ message: 'Create customer endpoint - coming soon!' });
});
// GET /api/customers/:id
router.get('/:id', (req, res) => {
res.json({ message: 'Get customer by ID endpoint - coming soon!' });
});
// PUT /api/customers/:id
router.put('/:id', (req, res) => {
res.json({ message: 'Update customer endpoint - coming soon!' });
});
export default router;

View file

@ -0,0 +1,30 @@
import { Router } from 'express';
const router = Router();
// GET /api/expenses
router.get('/', (req, res) => {
res.json({ message: 'Get expenses endpoint - coming soon!', expenses: [] });
});
// POST /api/expenses
router.post('/', (req, res) => {
res.json({ message: 'Create expense endpoint - coming soon!' });
});
// GET /api/expenses/:id
router.get('/:id', (req, res) => {
res.json({ message: 'Get expense by ID endpoint - coming soon!' });
});
// PUT /api/expenses/:id
router.put('/:id', (req, res) => {
res.json({ message: 'Update expense endpoint - coming soon!' });
});
// DELETE /api/expenses/:id
router.delete('/:id', (req, res) => {
res.json({ message: 'Delete expense endpoint - coming soon!' });
});
export default router;

View file

@ -0,0 +1,25 @@
import { Router } from 'express';
const router = Router();
// GET /api/orders
router.get('/', (req, res) => {
res.json({ message: 'Get orders endpoint - coming soon!', orders: [] });
});
// POST /api/orders
router.post('/', (req, res) => {
res.json({ message: 'Create order endpoint - coming soon!' });
});
// GET /api/orders/:id
router.get('/:id', (req, res) => {
res.json({ message: 'Get order by ID endpoint - coming soon!' });
});
// PUT /api/orders/:id
router.put('/:id', (req, res) => {
res.json({ message: 'Update order endpoint - coming soon!' });
});
export default router;

View file

@ -0,0 +1,30 @@
import { Router } from 'express';
const router = Router();
// GET /api/products
router.get('/', (req, res) => {
res.json({ message: 'Get products endpoint - coming soon!', products: [] });
});
// POST /api/products
router.post('/', (req, res) => {
res.json({ message: 'Create product endpoint - coming soon!' });
});
// GET /api/products/:id
router.get('/:id', (req, res) => {
res.json({ message: 'Get product by ID endpoint - coming soon!' });
});
// PUT /api/products/:id
router.put('/:id', (req, res) => {
res.json({ message: 'Update product endpoint - coming soon!' });
});
// DELETE /api/products/:id
router.delete('/:id', (req, res) => {
res.json({ message: 'Delete product endpoint - coming soon!' });
});
export default router;

20
server/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"removeComments": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}