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:
commit
9e1a098a70
62 changed files with 24281 additions and 0 deletions
57
.github/copilot-instructions.md
vendored
Normal file
57
.github/copilot-instructions.md
vendored
Normal 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
103
.gitignore
vendored
Normal 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
|
||||||
BIN
LayerXLayer_Business_Tracker.xlsx
Normal file
BIN
LayerXLayer_Business_Tracker.xlsx
Normal file
Binary file not shown.
287
README.md
Normal file
287
README.md
Normal 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
13
client/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>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
9483
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
53
client/package.json
Normal file
53
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
28
client/public/js/pdf.worker.min.js
vendored
Normal file
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
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
113
client/public/test-data.js
Normal 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
11
client/src/App.css
Normal 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
41
client/src/App.tsx
Normal 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;
|
||||||
45
client/src/components/Layout.tsx
Normal file
45
client/src/components/Layout.tsx
Normal 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;
|
||||||
171
client/src/components/MissingProductsModal.tsx
Normal file
171
client/src/components/MissingProductsModal.tsx
Normal 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
38
client/src/index.css
Normal 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
10
client/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
565
client/src/pages/Analytics.tsx
Normal file
565
client/src/pages/Analytics.tsx
Normal 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;
|
||||||
199
client/src/pages/Customers.tsx
Normal file
199
client/src/pages/Customers.tsx
Normal 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;
|
||||||
29
client/src/pages/Dashboard.tsx
Normal file
29
client/src/pages/Dashboard.tsx
Normal 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;
|
||||||
838
client/src/pages/DataImport.tsx
Normal file
838
client/src/pages/DataImport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
666
client/src/pages/Expenses.tsx
Normal file
666
client/src/pages/Expenses.tsx
Normal 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;
|
||||||
39
client/src/pages/Login.tsx
Normal file
39
client/src/pages/Login.tsx
Normal 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
955
client/src/pages/Orders.tsx
Normal 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;
|
||||||
597
client/src/pages/Products.tsx
Normal file
597
client/src/pages/Products.tsx
Normal 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;
|
||||||
505
client/src/pages/ProfitAnalysis.tsx
Normal file
505
client/src/pages/ProfitAnalysis.tsx
Normal 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 (<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;
|
||||||
193
client/src/pages/Settings.tsx
Normal file
193
client/src/pages/Settings.tsx
Normal 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
25
client/src/store/index.ts
Normal 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;
|
||||||
48
client/src/store/slices/authSlice.ts
Normal file
48
client/src/store/slices/authSlice.ts
Normal 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;
|
||||||
52
client/src/store/slices/customerSlice.ts
Normal file
52
client/src/store/slices/customerSlice.ts
Normal 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;
|
||||||
63
client/src/store/slices/expenseSlice.ts
Normal file
63
client/src/store/slices/expenseSlice.ts
Normal 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;
|
||||||
83
client/src/store/slices/orderSlice.ts
Normal file
83
client/src/store/slices/orderSlice.ts
Normal 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;
|
||||||
65
client/src/store/slices/productSlice.ts
Normal file
65
client/src/store/slices/productSlice.ts
Normal 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;
|
||||||
404
client/src/utils/csvImportService.ts
Normal file
404
client/src/utils/csvImportService.ts
Normal 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();
|
||||||
91
client/src/utils/dataManager.ts
Normal file
91
client/src/utils/dataManager.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
client/src/utils/dateFormatter.ts
Normal file
25
client/src/utils/dateFormatter.ts
Normal 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'
|
||||||
|
});
|
||||||
|
};
|
||||||
76
client/src/utils/orderCalculations.ts
Normal file
76
client/src/utils/orderCalculations.ts
Normal 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);
|
||||||
|
};
|
||||||
263
client/src/utils/pdfParser.ts
Normal file
263
client/src/utils/pdfParser.ts
Normal 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();
|
||||||
184
client/src/utils/productMatcher.ts
Normal file
184
client/src/utils/productMatcher.ts
Normal 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];
|
||||||
|
};
|
||||||
473
client/src/utils/profitAnalysisService.ts
Normal file
473
client/src/utils/profitAnalysisService.ts
Normal 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;
|
||||||
43
client/src/utils/testActualPDF.ts
Normal file
43
client/src/utils/testActualPDF.ts
Normal 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()');
|
||||||
|
}
|
||||||
54
client/src/utils/testDateParsing.ts
Normal file
54
client/src/utils/testDateParsing.ts
Normal 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();
|
||||||
18
client/src/utils/testPDF.ts
Normal file
18
client/src/utils/testPDF.ts
Normal 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
11
client/tailwind.config.js
Normal 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
31
client/tsconfig.json
Normal 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
10
client/tsconfig.node.json
Normal 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
23
client/vite.config.ts
Normal 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
373
package-lock.json
generated
Normal 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
27
package.json
Normal 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
13
server/.env.example
Normal 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
6280
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46
server/package.json
Normal file
46
server/package.json
Normal 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
83
server/src/index.ts
Normal 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}`);
|
||||||
|
});
|
||||||
90
server/src/models/Order.ts
Normal file
90
server/src/models/Order.ts
Normal 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);
|
||||||
75
server/src/models/Product.ts
Normal file
75
server/src/models/Product.ts
Normal 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);
|
||||||
35
server/src/routes/analytics.ts
Normal file
35
server/src/routes/analytics.ts
Normal 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
20
server/src/routes/auth.ts
Normal 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;
|
||||||
25
server/src/routes/customers.ts
Normal file
25
server/src/routes/customers.ts
Normal 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;
|
||||||
30
server/src/routes/expenses.ts
Normal file
30
server/src/routes/expenses.ts
Normal 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;
|
||||||
25
server/src/routes/orders.ts
Normal file
25
server/src/routes/orders.ts
Normal 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;
|
||||||
30
server/src/routes/products.ts
Normal file
30
server/src/routes/products.ts
Normal 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
20
server/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue