first commit
This commit is contained in:
commit
2cc2cbb7a3
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
|
Use icons from lucide-react for logos.
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.env
|
24
Dockerfile.frontend
Normal file
24
Dockerfile.frontend
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Install a simple HTTP server for serving static content
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Serve the built application
|
||||||
|
CMD ["serve", "-s", "dist", "-l", "3000"]
|
106
README.md
Normal file
106
README.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Cashback Tracker
|
||||||
|
|
||||||
|
A comprehensive web application for tracking debit card cashback rewards. Built with React + TypeScript frontend, Node.js + Express + SQLite backend, and deployed with Docker Compose.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Card Management**: Add, edit, and delete debit cards with details
|
||||||
|
- **Category Management**: Manage cashback categories (groceries, gas, etc.)
|
||||||
|
- **Monthly Category Assignment**: Assign categories to cards with specific cashback percentages each month
|
||||||
|
- **Transaction Tracking**: Record purchases and automatically calculate cashback
|
||||||
|
- **Dashboard**: View current month's cashback summary with filtering options
|
||||||
|
- **Responsive Design**: Works seamlessly on desktop and mobile devices
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: React 18, TypeScript, TailwindCSS, Shadcn/UI, React Router
|
||||||
|
- **Backend**: Node.js, Express.js, SQLite3
|
||||||
|
- **Deployment**: Docker & Docker Compose
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Run the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access the application:
|
||||||
|
- Frontend: http://localhost:3000
|
||||||
|
- Backend API: http://localhost:3001
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
- **cards**: Store debit card information
|
||||||
|
- **categories**: Cashback categories (groceries, gas, etc.)
|
||||||
|
- **monthly_categories**: Monthly assignment of categories to cards with percentages
|
||||||
|
- **transactions**: Purchase records with automatic cashback calculation
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/cards` - Get all cards
|
||||||
|
- `POST /api/cards` - Create new card
|
||||||
|
- `GET /api/categories` - Get all categories
|
||||||
|
- `POST /api/categories` - Create new category
|
||||||
|
- `GET /api/monthly-categories/:year/:month` - Get monthly assignments
|
||||||
|
- `POST /api/monthly-categories` - Create monthly assignment
|
||||||
|
- `GET /api/transactions/:year/:month` - Get transactions by month
|
||||||
|
- `POST /api/transactions` - Create transaction (auto-calculates cashback)
|
||||||
|
- `GET /api/cashback/:year/:month` - Get cashback summary with filtering
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Setup Cards**: Add your debit cards with bank information
|
||||||
|
2. **Create Categories**: Define cashback categories (groceries, gas, dining, etc.)
|
||||||
|
3. **Monthly Assignment**: Each month, assign categories to cards with their cashback percentages
|
||||||
|
4. **Track Transactions**: Add purchases to automatically calculate and track cashback
|
||||||
|
5. **Monitor Dashboard**: View monthly summaries and filter by card or category
|
||||||
|
|
||||||
|
## Docker Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Run in background
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Remove volumes (reset database)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Commit your changes
|
||||||
|
4. Push to the branch
|
||||||
|
5. Create a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "src/components",
|
||||||
|
"utils": "src/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://backend:3001
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- sqlite_data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_PATH=/app/data/cashback.db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlite_data:
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Complete Cashback Tracking Application</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
5076
package-lock.json
generated
Normal file
5076
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "vite-react-typescript-starter",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.8.2",
|
||||||
|
"tailwind-merge": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
21
server/Dockerfile
Normal file
21
server/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create directory for database
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["npm", "start"]
|
BIN
server/cashback.db
Normal file
BIN
server/cashback.db
Normal file
Binary file not shown.
73
server/database.js
Normal file
73
server/database.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = process.env.NODE_ENV === 'production'
|
||||||
|
? '/app/data/cashback.db'
|
||||||
|
: path.join(__dirname, 'cashback.db');
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error opening database:', err);
|
||||||
|
} else {
|
||||||
|
console.log('Connected to SQLite database');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize tables
|
||||||
|
db.serialize(() => {
|
||||||
|
// Cards table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bank TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Categories table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Monthly categories table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monthly_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
card_id INTEGER NOT NULL,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
month INTEGER NOT NULL,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
cashback_percent REAL NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES cards (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(card_id, category_id, month, year)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Transactions table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
card_id INTEGER NOT NULL,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
cashback_amount REAL NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES cards (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = db;
|
29
server/index.js
Normal file
29
server/index.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/cards', require('./routes/cards'));
|
||||||
|
app.use('/api/categories', require('./routes/categories'));
|
||||||
|
app.use('/api/monthly-categories', require('./routes/monthly-categories'));
|
||||||
|
app.use('/api/transactions', require('./routes/transactions'));
|
||||||
|
app.use('/api/cashback', require('./routes/cashback'));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
2651
server/package-lock.json
generated
Normal file
2651
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
server/package.json
Normal file
18
server/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "cashback-tracker-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend server for cashback tracker",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "nodemon index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"sqlite3": "^5.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
101
server/routes/cards.js
Normal file
101
server/routes/cards.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// Get all cards
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
db.all('SELECT * FROM cards ORDER BY created_at DESC', (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get card by ID
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.get('SELECT * FROM cards WHERE id = ?', [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (!row) {
|
||||||
|
res.status(404).json({ error: 'Card not found' });
|
||||||
|
} else {
|
||||||
|
res.json(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new card
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { name, bank, description, image_url } = req.body;
|
||||||
|
|
||||||
|
if (!name || !bank) {
|
||||||
|
return res.status(400).json({ error: 'Name and bank are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO cards (name, bank, description, image_url) VALUES (?, ?, ?, ?)',
|
||||||
|
[name, bank, description, image_url],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
name,
|
||||||
|
bank,
|
||||||
|
description,
|
||||||
|
image_url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update card
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, bank, description, image_url } = req.body;
|
||||||
|
|
||||||
|
if (!name || !bank) {
|
||||||
|
return res.status(400).json({ error: 'Name and bank are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'UPDATE cards SET name = ?, bank = ?, description = ?, image_url = ? WHERE id = ?',
|
||||||
|
[name, bank, description, image_url, id],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Card not found' });
|
||||||
|
} else {
|
||||||
|
res.json({ id: parseInt(id), name, bank, description, image_url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete card
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.run('DELETE FROM cards WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Card not found' });
|
||||||
|
} else {
|
||||||
|
res.json({ message: 'Card deleted successfully' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
93
server/routes/cashback.js
Normal file
93
server/routes/cashback.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// Get cashback summary by month/year with optional filters
|
||||||
|
router.get('/:year/:month', (req, res) => {
|
||||||
|
const { year, month } = req.params;
|
||||||
|
const { card_id, category_id } = req.query;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
c.id as card_id,
|
||||||
|
c.name as card_name,
|
||||||
|
c.bank,
|
||||||
|
cat.id as category_id,
|
||||||
|
cat.name as category_name,
|
||||||
|
mc.cashback_percent,
|
||||||
|
COALESCE(SUM(t.amount), 0) as total_spent,
|
||||||
|
COALESCE(SUM(t.cashback_amount), 0) as total_cashback,
|
||||||
|
COUNT(t.id) as transaction_count
|
||||||
|
FROM monthly_categories mc
|
||||||
|
JOIN cards c ON mc.card_id = c.id
|
||||||
|
JOIN categories cat ON mc.category_id = cat.id
|
||||||
|
LEFT JOIN transactions t ON t.card_id = mc.card_id
|
||||||
|
AND t.category_id = mc.category_id
|
||||||
|
AND strftime('%Y', t.date) = ?
|
||||||
|
AND strftime('%m', t.date) = ?
|
||||||
|
WHERE mc.year = ? AND mc.month = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const queryParams = [year, month.toString().padStart(2, '0'), year, month];
|
||||||
|
|
||||||
|
// Add filters
|
||||||
|
if (card_id) {
|
||||||
|
query += ' AND c.id = ?';
|
||||||
|
queryParams.push(card_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category_id) {
|
||||||
|
query += ' AND cat.id = ?';
|
||||||
|
queryParams.push(category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' GROUP BY c.id, cat.id ORDER BY c.name, cat.name';
|
||||||
|
|
||||||
|
db.all(query, queryParams, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
// Calculate totals
|
||||||
|
const totalSpent = rows.reduce((sum, row) => sum + row.total_spent, 0);
|
||||||
|
const totalCashback = rows.reduce((sum, row) => sum + row.total_cashback, 0);
|
||||||
|
const totalTransactions = rows.reduce((sum, row) => sum + row.transaction_count, 0);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
summary: {
|
||||||
|
total_spent: totalSpent,
|
||||||
|
total_cashback: totalCashback,
|
||||||
|
total_transactions: totalTransactions,
|
||||||
|
average_cashback_rate: totalSpent > 0 ? (totalCashback / totalSpent) * 100 : 0
|
||||||
|
},
|
||||||
|
details: rows
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get overall cashback statistics
|
||||||
|
router.get('/stats/overview', (req, res) => {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT c.id) as total_cards,
|
||||||
|
COUNT(DISTINCT cat.id) as total_categories,
|
||||||
|
COALESCE(SUM(t.amount), 0) as lifetime_spent,
|
||||||
|
COALESCE(SUM(t.cashback_amount), 0) as lifetime_cashback,
|
||||||
|
COUNT(t.id) as lifetime_transactions
|
||||||
|
FROM cards c
|
||||||
|
CROSS JOIN categories cat
|
||||||
|
LEFT JOIN transactions t ON 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(query, (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.json(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
99
server/routes/categories.js
Normal file
99
server/routes/categories.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// Get all categories
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
db.all('SELECT * FROM categories ORDER BY name', (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get category by ID
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.get('SELECT * FROM categories WHERE id = ?', [id], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (!row) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
} else {
|
||||||
|
res.json(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new category
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO categories (name, description) VALUES (?, ?)',
|
||||||
|
[name, description],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update category
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'UPDATE categories SET name = ?, description = ? WHERE id = ?',
|
||||||
|
[name, description, id],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
} else {
|
||||||
|
res.json({ id: parseInt(id), name, description });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete category
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.run('DELETE FROM categories WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
} else {
|
||||||
|
res.json({ message: 'Category deleted successfully' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
99
server/routes/monthly-categories.js
Normal file
99
server/routes/monthly-categories.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// Get monthly categories by year and month
|
||||||
|
router.get('/:year/:month', (req, res) => {
|
||||||
|
const { year, month } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT mc.*, c.name as card_name, c.bank, cat.name as category_name
|
||||||
|
FROM monthly_categories mc
|
||||||
|
JOIN cards c ON mc.card_id = c.id
|
||||||
|
JOIN categories cat ON mc.category_id = cat.id
|
||||||
|
WHERE mc.year = ? AND mc.month = ?
|
||||||
|
ORDER BY c.name, cat.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(query, [year, month], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create monthly category assignment
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { card_id, category_id, month, year, cashback_percent } = req.body;
|
||||||
|
|
||||||
|
if (!card_id || !category_id || !month || !year || cashback_percent === undefined) {
|
||||||
|
return res.status(400).json({ error: 'All fields are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO monthly_categories
|
||||||
|
(card_id, category_id, month, year, cashback_percent)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[card_id, category_id, month, year, cashback_percent],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
card_id,
|
||||||
|
category_id,
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
cashback_percent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete monthly category assignment
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.run('DELETE FROM monthly_categories WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Assignment not found' });
|
||||||
|
} else {
|
||||||
|
res.json({ message: 'Assignment deleted successfully' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get available categories for a card in a specific month/year
|
||||||
|
router.get('/available/:card_id/:year/:month', (req, res) => {
|
||||||
|
const { card_id, year, month } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT c.* FROM categories c
|
||||||
|
WHERE c.id NOT IN (
|
||||||
|
SELECT mc.category_id
|
||||||
|
FROM monthly_categories mc
|
||||||
|
WHERE mc.card_id = ? AND mc.year = ? AND mc.month = ?
|
||||||
|
)
|
||||||
|
ORDER BY c.name
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.all(query, [card_id, year, month], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
162
server/routes/transactions.js
Normal file
162
server/routes/transactions.js
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
// Get transactions by month/year
|
||||||
|
router.get('/:year/:month', (req, res) => {
|
||||||
|
const { year, month } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT t.*, c.name as card_name, c.bank, cat.name as category_name
|
||||||
|
FROM transactions t
|
||||||
|
JOIN cards c ON t.card_id = c.id
|
||||||
|
JOIN categories cat ON t.category_id = cat.id
|
||||||
|
WHERE strftime('%Y', t.date) = ? AND strftime('%m', t.date) = ?
|
||||||
|
ORDER BY t.date DESC, t.created_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pad month with zero if needed
|
||||||
|
const paddedMonth = month.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
db.all(query, [year, paddedMonth], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create transaction
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { card_id, category_id, amount, date, description } = req.body;
|
||||||
|
|
||||||
|
if (!card_id || !category_id || !amount || !date) {
|
||||||
|
return res.status(400).json({ error: 'Card, category, amount, and date are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, get the cashback percentage for this card/category combination
|
||||||
|
const transactionDate = new Date(date);
|
||||||
|
const month = transactionDate.getMonth() + 1;
|
||||||
|
const year = transactionDate.getFullYear();
|
||||||
|
|
||||||
|
const getCashbackQuery = `
|
||||||
|
SELECT cashback_percent
|
||||||
|
FROM monthly_categories
|
||||||
|
WHERE card_id = ? AND category_id = ? AND month = ? AND year = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(getCashbackQuery, [card_id, category_id, month, year], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'No cashback percentage found for this card/category combination in the specified month'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashback_amount = (amount * row.cashback_percent) / 100;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO transactions (card_id, category_id, amount, cashback_amount, date, description) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[card_id, category_id, amount, cashback_amount, date, description],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else {
|
||||||
|
res.status(201).json({
|
||||||
|
id: this.lastID,
|
||||||
|
card_id,
|
||||||
|
category_id,
|
||||||
|
amount,
|
||||||
|
cashback_amount,
|
||||||
|
date,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update transaction
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { card_id, category_id, amount, date, description } = req.body;
|
||||||
|
|
||||||
|
if (!card_id || !category_id || !amount || !date) {
|
||||||
|
return res.status(400).json({ error: 'Card, category, amount, and date are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate cashback amount
|
||||||
|
const transactionDate = new Date(date);
|
||||||
|
const month = transactionDate.getMonth() + 1;
|
||||||
|
const year = transactionDate.getFullYear();
|
||||||
|
|
||||||
|
const getCashbackQuery = `
|
||||||
|
SELECT cashback_percent
|
||||||
|
FROM monthly_categories
|
||||||
|
WHERE card_id = ? AND category_id = ? AND month = ? AND year = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.get(getCashbackQuery, [card_id, category_id, month, year], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'No cashback percentage found for this card/category combination in the specified month'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashback_amount = (amount * row.cashback_percent) / 100;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'UPDATE transactions SET card_id = ?, category_id = ?, amount = ?, cashback_amount = ?, date = ?, description = ? WHERE id = ?',
|
||||||
|
[card_id, category_id, amount, cashback_amount, date, description, id],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Transaction not found' });
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
id: parseInt(id),
|
||||||
|
card_id,
|
||||||
|
category_id,
|
||||||
|
amount,
|
||||||
|
cashback_amount,
|
||||||
|
date,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete transaction
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.run('DELETE FROM transactions WHERE id = ?', [id], function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
} else if (this.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Transaction not found' });
|
||||||
|
} else {
|
||||||
|
res.json({ message: 'Transaction deleted successfully' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
25
src/App.tsx
Normal file
25
src/App.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
|
import { Cards } from '@/pages/Cards';
|
||||||
|
import { Categories } from '@/pages/Categories';
|
||||||
|
import { Monthly } from '@/pages/Monthly';
|
||||||
|
import { Transactions } from '@/pages/Transactions';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/cards" element={<Cards />} />
|
||||||
|
<Route path="/categories" element={<Categories />} />
|
||||||
|
<Route path="/monthly" element={<Monthly />} />
|
||||||
|
<Route path="/transactions" element={<Transactions />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
91
src/components/Layout.tsx
Normal file
91
src/components/Layout.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { CreditCard, Tag, Calendar, BarChart3, Receipt } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: BarChart3 },
|
||||||
|
{ name: 'Cards', href: '/cards', icon: CreditCard },
|
||||||
|
{ name: 'Categories', href: '/categories', icon: Tag },
|
||||||
|
{ name: 'Monthly Setup', href: '/monthly', icon: Calendar },
|
||||||
|
{ name: 'Transactions', href: '/transactions', icon: Receipt },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
|
||||||
|
<div className="flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 bg-white shadow-xl">
|
||||||
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
|
<div className="flex items-center flex-shrink-0 px-4">
|
||||||
|
<CreditCard className="h-8 w-8 text-blue-600" />
|
||||||
|
<span className="ml-2 text-xl font-bold text-gray-900">CashTracker</span>
|
||||||
|
</div>
|
||||||
|
<nav className="mt-8 flex-1 px-2 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center px-2 py-2 text-sm font-medium rounded-md transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-100 text-blue-900'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
'mr-3 h-5 w-5',
|
||||||
|
isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="md:pl-64 flex flex-col flex-1">
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile bottom navigation */}
|
||||||
|
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg">
|
||||||
|
<nav className="flex justify-around py-2">
|
||||||
|
{navigation.slice(0, 4).map((item) => {
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center py-2 px-1 text-xs font-medium',
|
||||||
|
isActive ? 'text-blue-600' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
<span className="mt-1">{item.name}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
66
src/index.css
Normal file
66
src/index.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 84% 4.9%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 84% 4.9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 84% 4.9%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 94.1%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile bottom navigation spacing */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-safe {
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
}
|
78
src/lib/api.ts
Normal file
78
src/lib/api.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Card, Category, MonthlyCategory, Transaction } from '../types';
|
||||||
|
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ? `${import.meta.env.VITE_API_URL}/api` : '/api';
|
||||||
|
|
||||||
|
async function fetchAPI(endpoint: string, options: RequestInit = {}) {
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards API
|
||||||
|
export const cardsAPI = {
|
||||||
|
getAll: () => fetchAPI('/cards'),
|
||||||
|
getById: (id: number) => fetchAPI(`/cards/${id}`),
|
||||||
|
create: (card: Omit<Card, 'id'>) =>
|
||||||
|
fetchAPI('/cards', { method: 'POST', body: JSON.stringify(card) }),
|
||||||
|
update: (id: number, card: Partial<Card>) =>
|
||||||
|
fetchAPI(`/cards/${id}`, { method: 'PUT', body: JSON.stringify(card) }),
|
||||||
|
delete: (id: number) => fetchAPI(`/cards/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Categories API
|
||||||
|
export const categoriesAPI = {
|
||||||
|
getAll: () => fetchAPI('/categories'),
|
||||||
|
getById: (id: number) => fetchAPI(`/categories/${id}`),
|
||||||
|
create: (category: Omit<Category, 'id'>) =>
|
||||||
|
fetchAPI('/categories', { method: 'POST', body: JSON.stringify(category) }),
|
||||||
|
update: (id: number, category: Partial<Category>) =>
|
||||||
|
fetchAPI(`/categories/${id}`, { method: 'PUT', body: JSON.stringify(category) }),
|
||||||
|
delete: (id: number) => fetchAPI(`/categories/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Monthly Categories API
|
||||||
|
export const monthlyCategoriesAPI = {
|
||||||
|
getByMonth: (year: number, month: number) =>
|
||||||
|
fetchAPI(`/monthly-categories/${year}/${month}`),
|
||||||
|
create: (monthlyCategory: Omit<MonthlyCategory, 'id'>) =>
|
||||||
|
fetchAPI('/monthly-categories', { method: 'POST', body: JSON.stringify(monthlyCategory) }),
|
||||||
|
update: (id: number, monthlyCategory: Partial<MonthlyCategory>) =>
|
||||||
|
fetchAPI(`/monthly-categories/${id}`, { method: 'PUT', body: JSON.stringify(monthlyCategory) }),
|
||||||
|
delete: (id: number) => fetchAPI(`/monthly-categories/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transactions API
|
||||||
|
export const transactionsAPI = {
|
||||||
|
getAll: () => fetchAPI('/transactions'),
|
||||||
|
getByMonth: (year: number, month: number) =>
|
||||||
|
fetchAPI(`/transactions/${year}/${month}`),
|
||||||
|
create: (transaction: Omit<Transaction, 'id' | 'cashback_amount'>) =>
|
||||||
|
fetchAPI('/transactions', { method: 'POST', body: JSON.stringify(transaction) }),
|
||||||
|
update: (id: number, transaction: Partial<Transaction>) =>
|
||||||
|
fetchAPI(`/transactions/${id}`, { method: 'PUT', body: JSON.stringify(transaction) }),
|
||||||
|
delete: (id: number) => fetchAPI(`/transactions/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cashback Summary API
|
||||||
|
export const cashbackAPI = {
|
||||||
|
getSummary: (year: number, month: number, cardId?: number, categoryId?: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (cardId) params.append('cardId', cardId.toString());
|
||||||
|
if (categoryId) params.append('categoryId', categoryId.toString());
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return fetchAPI(`/cashback/${year}/${month}${queryString ? `?${queryString}` : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
24
src/lib/utils.ts
Normal file
24
src/lib/utils.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentMonth(): string {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMonth(monthYear: string): string {
|
||||||
|
const [year, month] = monthYear.split('-')
|
||||||
|
const date = new Date(parseInt(year), parseInt(month) - 1)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||||
|
}
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
310
src/pages/Cards.tsx
Normal file
310
src/pages/Cards.tsx
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Edit, Trash2, CreditCard, Upload, X } from 'lucide-react';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog';
|
||||||
|
import { Input } from '../components/ui/input';
|
||||||
|
import { Label } from '../components/ui/label';
|
||||||
|
import { cardsAPI } from '../lib/api';
|
||||||
|
import type { Card as CardType } from '../types';
|
||||||
|
|
||||||
|
export function Cards() {
|
||||||
|
const [cards, setCards] = useState<CardType[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingCard, setEditingCard] = useState<CardType | null>(null);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
bank: '',
|
||||||
|
description: '',
|
||||||
|
image_url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCards = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await cardsAPI.getAll();
|
||||||
|
setCards(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch cards:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCards();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
let imageUrl = formData.image_url;
|
||||||
|
|
||||||
|
// If a new image was selected, convert to base64
|
||||||
|
if (selectedImage) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
imageUrl = await new Promise((resolve) => {
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(selectedImage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardData = { ...formData, image_url: imageUrl };
|
||||||
|
|
||||||
|
if (editingCard) {
|
||||||
|
await cardsAPI.update(editingCard.id, cardData);
|
||||||
|
} else {
|
||||||
|
await cardsAPI.create(cardData);
|
||||||
|
}
|
||||||
|
await fetchCards();
|
||||||
|
handleCloseDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save card:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (confirm('Are you sure you want to delete this card?')) {
|
||||||
|
try {
|
||||||
|
await cardsAPI.delete(id);
|
||||||
|
await fetchCards();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete card:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDialog = (card?: CardType) => {
|
||||||
|
if (card) {
|
||||||
|
setEditingCard(card);
|
||||||
|
setFormData({
|
||||||
|
name: card.name,
|
||||||
|
bank: card.bank,
|
||||||
|
description: card.description,
|
||||||
|
image_url: card.image_url || ''
|
||||||
|
});
|
||||||
|
setImagePreview(card.image_url || null);
|
||||||
|
} else {
|
||||||
|
setEditingCard(null);
|
||||||
|
setFormData({ name: '', bank: '', description: '', image_url: '' });
|
||||||
|
setImagePreview(null);
|
||||||
|
}
|
||||||
|
setSelectedImage(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingCard(null);
|
||||||
|
setFormData({ name: '', bank: '', description: '', image_url: '' });
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 5 * 1024 * 1024) { // 5MB limit
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedImage(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setImagePreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
setSelectedImage(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
setFormData({ ...formData, image_url: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-48 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Cards</h1>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Card
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCard ? 'Edit Card' : 'Add New Card'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="image">Card Image</Label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Card preview"
|
||||||
|
className="w-32 h-20 object-cover rounded-lg border"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={removeImage}
|
||||||
|
className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||||
|
<Upload className="h-8 w-8 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600 mb-2">Upload card image</p>
|
||||||
|
<Input
|
||||||
|
id="image"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => document.getElementById('image')?.click()}
|
||||||
|
>
|
||||||
|
Choose Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Recommended: 320x200px, max 5MB (JPG, PNG, WebP)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Card Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., Chase Freedom"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="bank">Bank</Label>
|
||||||
|
<Input
|
||||||
|
id="bank"
|
||||||
|
value={formData.bank}
|
||||||
|
onChange={(e) => setFormData({ ...formData, bank: e.target.value })}
|
||||||
|
placeholder="e.g., Chase Bank"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editingCard ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cards.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Card key={card.id} className="hover:shadow-lg transition-shadow">
|
||||||
|
{card.image_url && (
|
||||||
|
<div className="aspect-[8/5] overflow-hidden rounded-t-xl">
|
||||||
|
<img
|
||||||
|
src={card.image_url}
|
||||||
|
alt={`${card.name} card`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-blue-600" />
|
||||||
|
{card.name}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600">{card.bank}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-700 mb-4">{card.description || 'No description'}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDialog(card)}
|
||||||
|
className="flex-1 gap-1"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(card.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<CardContent>
|
||||||
|
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No cards yet</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Add your first debit card to start tracking cashback rewards.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Your First Card
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
198
src/pages/Categories.tsx
Normal file
198
src/pages/Categories.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Edit, Trash2, Tag } from 'lucide-react';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog';
|
||||||
|
import { Input } from '../components/ui/input';
|
||||||
|
import { Label } from '../components/ui/label';
|
||||||
|
import { categoriesAPI } from '../lib/api';
|
||||||
|
import type { Category } from '../types';
|
||||||
|
|
||||||
|
export function Categories() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await categoriesAPI.getAll();
|
||||||
|
setCategories(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch categories:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
await categoriesAPI.update(editingCategory.id, formData);
|
||||||
|
} else {
|
||||||
|
await categoriesAPI.create(formData);
|
||||||
|
}
|
||||||
|
await fetchCategories();
|
||||||
|
handleCloseDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save category:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (confirm('Are you sure you want to delete this category?')) {
|
||||||
|
try {
|
||||||
|
await categoriesAPI.delete(id);
|
||||||
|
await fetchCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete category:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDialog = (category?: Category) => {
|
||||||
|
if (category) {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setFormData({
|
||||||
|
name: category.name,
|
||||||
|
description: category.description
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({ name: '', description: '' });
|
||||||
|
}
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({ name: '', description: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-48 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Categories</h1>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Category
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCategory ? 'Edit Category' : 'Add New Category'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Category Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., Groceries"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editingCategory ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categories.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Card key={category.id} className="hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Tag className="h-5 w-5 text-emerald-600" />
|
||||||
|
{category.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-700 mb-4">{category.description || 'No description'}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDialog(category)}
|
||||||
|
className="flex-1 gap-1"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(category.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<CardContent>
|
||||||
|
<Tag className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No categories yet</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Add cashback categories to organize your spending and rewards.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Your First Category
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
279
src/pages/Dashboard.tsx
Normal file
279
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
import { CreditCard, TrendingUp, DollarSign, Percent } from 'lucide-react';
|
||||||
|
import { formatCurrency, getCurrentMonth, formatMonth } from '../lib/utils';
|
||||||
|
import { cashbackAPI, cardsAPI, categoriesAPI } from '../lib/api';
|
||||||
|
import type { CashbackSummary, Card as CardType, Category } from '../types';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [summary, setSummary] = useState<CashbackSummary | null>(null);
|
||||||
|
const [cards, setCards] = useState<CardType[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [selectedCard, setSelectedCard] = useState<string>('all');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const currentMonth = getCurrentMonth();
|
||||||
|
const [year, month] = currentMonth.split('-').map(Number);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [summaryData, cardsData, categoriesData] = await Promise.all([
|
||||||
|
cashbackAPI.getSummary(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
selectedCard !== 'all' ? Number(selectedCard) : undefined,
|
||||||
|
selectedCategory !== 'all' ? Number(selectedCategory) : undefined
|
||||||
|
),
|
||||||
|
cardsAPI.getAll(),
|
||||||
|
categoriesAPI.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setSummary(summaryData);
|
||||||
|
setCards(cardsData);
|
||||||
|
setCategories(categoriesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [selectedCard, selectedCategory, year, month]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-32 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Cashback Dashboard - {formatMonth(currentMonth)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Select value={selectedCard} onValueChange={setSelectedCard}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Filter by card" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Cards</SelectItem>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<SelectItem key={card.id} value={card.id.toString()}>
|
||||||
|
{card.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Filter by category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id.toString()}>
|
||||||
|
{category.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card className="bg-gradient-to-r from-blue-500 to-blue-600 text-white border-0">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Cashback</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(summary?.total_cashback || 0)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-0">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(summary?.total_spent || 0)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-r from-purple-500 to-purple-600 text-white border-0">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg. Cashback Rate</CardTitle>
|
||||||
|
<Percent className="h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{summary?.total_spent ?
|
||||||
|
((summary.total_cashback / summary.total_spent) * 100).toFixed(2) + '%' :
|
||||||
|
'0%'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Breakdown */}
|
||||||
|
{summary?.card_summaries && summary.card_summaries.length > 0 ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Category Cashback Summary */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-6">Cashback by Category</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="space-y-0">
|
||||||
|
{(() => {
|
||||||
|
// Aggregate cashback by category across all cards
|
||||||
|
const categoryTotals = new Map();
|
||||||
|
|
||||||
|
summary.card_summaries.forEach(({ categories }) => {
|
||||||
|
categories.forEach(({ category, cashback_amount, spent_amount, cashback_percent }) => {
|
||||||
|
const existing = categoryTotals.get(category.id) || {
|
||||||
|
category,
|
||||||
|
total_cashback: 0,
|
||||||
|
total_spent: 0,
|
||||||
|
avg_cashback_percent: 0,
|
||||||
|
card_count: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
existing.total_cashback += cashback_amount;
|
||||||
|
existing.total_spent += spent_amount;
|
||||||
|
existing.avg_cashback_percent += cashback_percent;
|
||||||
|
existing.card_count += 1;
|
||||||
|
|
||||||
|
categoryTotals.set(category.id, existing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by cashback amount
|
||||||
|
const sortedCategories = Array.from(categoryTotals.values())
|
||||||
|
.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
avg_cashback_percent: cat.avg_cashback_percent / cat.card_count
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total_cashback - a.total_cashback);
|
||||||
|
|
||||||
|
return sortedCategories.map(({ category, total_cashback, total_spent, avg_cashback_percent }) => (
|
||||||
|
<div key={category.id} className="flex items-center justify-between p-4 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-emerald-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-emerald-600 font-semibold text-sm">
|
||||||
|
{category.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{category.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{avg_cashback_percent.toFixed(1)}% avg cashback rate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-emerald-600">
|
||||||
|
{formatCurrency(total_cashback)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
from {formatCurrency(total_spent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Breakdown */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-6">Cashback by Card</h2>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{summary.card_summaries.map(({ card, total_cashback, total_spent, categories }) => (
|
||||||
|
<Card key={card.id} className="hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-blue-600" />
|
||||||
|
{card.name}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{card.bank}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">
|
||||||
|
{formatCurrency(total_cashback)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
of {formatCurrency(total_spent)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{categories.map(({ category, cashback_percent, cashback_amount, spent_amount }) => (
|
||||||
|
<div key={category.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{category.name}</p>
|
||||||
|
<Badge variant="secondary" className="mt-1">
|
||||||
|
{cashback_percent}% cashback
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium text-emerald-600">
|
||||||
|
{formatCurrency(cashback_amount)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{formatCurrency(spent_amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<CardContent>
|
||||||
|
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No cashback data available</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Set up your cards and categories, then add some transactions to see your cashback summary.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
306
src/pages/Monthly.tsx
Normal file
306
src/pages/Monthly.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Edit, Trash2, Calendar, CreditCard, Tag } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { monthlyCategoriesAPI, cardsAPI, categoriesAPI } from '@/lib/api';
|
||||||
|
import { getCurrentMonth, formatMonth } from '@/lib/utils';
|
||||||
|
import type { MonthlyCategory, Card as CardType, Category } from '@/types';
|
||||||
|
|
||||||
|
function getNextMonth(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
return `${nextMonth.getFullYear()}-${(nextMonth.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
export function Monthly() {
|
||||||
|
const [monthlyCategories, setMonthlyCategories] = useState<MonthlyCategory[]>([]);
|
||||||
|
const [cards, setCards] = useState<CardType[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingMonthlyCategory, setEditingMonthlyCategory] = useState<MonthlyCategory | null>(null);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(getNextMonth());
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
card_id: '',
|
||||||
|
category_id: '',
|
||||||
|
cashback_percent: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [year, month] = selectedMonth.split('-').map(Number);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [monthlyData, cardsData, categoriesData] = await Promise.all([
|
||||||
|
monthlyCategoriesAPI.getByMonth(year, month),
|
||||||
|
cardsAPI.getAll(),
|
||||||
|
categoriesAPI.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setMonthlyCategories(monthlyData);
|
||||||
|
setCards(cardsData);
|
||||||
|
setCategories(categoriesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
card_id: Number(formData.card_id),
|
||||||
|
category_id: Number(formData.category_id),
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
cashback_percent: Number(formData.cashback_percent)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingMonthlyCategory) {
|
||||||
|
await monthlyCategoriesAPI.update(editingMonthlyCategory.id, data);
|
||||||
|
} else {
|
||||||
|
await monthlyCategoriesAPI.create(data);
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
handleCloseDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save monthly category:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (confirm('Are you sure you want to delete this monthly category assignment?')) {
|
||||||
|
try {
|
||||||
|
await monthlyCategoriesAPI.delete(id);
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete monthly category:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDialog = (monthlyCategory?: MonthlyCategory) => {
|
||||||
|
if (monthlyCategory) {
|
||||||
|
setEditingMonthlyCategory(monthlyCategory);
|
||||||
|
setFormData({
|
||||||
|
card_id: monthlyCategory.card_id.toString(),
|
||||||
|
category_id: monthlyCategory.category_id.toString(),
|
||||||
|
cashback_percent: monthlyCategory.cashback_percent.toString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingMonthlyCategory(null);
|
||||||
|
setFormData({ card_id: '', category_id: '', cashback_percent: '' });
|
||||||
|
}
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingMonthlyCategory(null);
|
||||||
|
setFormData({ card_id: '', category_id: '', cashback_percent: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-48 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedByCard = monthlyCategories.reduce((acc, mc) => {
|
||||||
|
const cardId = mc.card_id;
|
||||||
|
if (!acc[cardId]) {
|
||||||
|
acc[cardId] = [];
|
||||||
|
}
|
||||||
|
acc[cardId].push(mc);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, MonthlyCategory[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Monthly Category Setup</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
type="month"
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(e) => setSelectedMonth(e.target.value)}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Assign Category
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingMonthlyCategory ? 'Edit Assignment' : 'Assign Category to Card'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="card_id">Card</Label>
|
||||||
|
<Select value={formData.card_id} onValueChange={(value) => setFormData({ ...formData, card_id: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a card" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<SelectItem key={card.id} value={card.id.toString()}>
|
||||||
|
{card.name} ({card.bank})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category_id">Category</Label>
|
||||||
|
<Select value={formData.category_id} onValueChange={(value) => setFormData({ ...formData, category_id: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id.toString()}>
|
||||||
|
{category.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cashback_percent">Cashback Percentage</Label>
|
||||||
|
<Input
|
||||||
|
id="cashback_percent"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.cashback_percent}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cashback_percent: e.target.value })}
|
||||||
|
placeholder="e.g., 5.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editingMonthlyCategory ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-blue-600" />
|
||||||
|
Cashback Valid for {formatMonth(selectedMonth)}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Set up cashback categories that will be active during {formatMonth(selectedMonth)}.
|
||||||
|
Categories are typically assigned at the end of the current month for the following month.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{Object.keys(groupedByCard).length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{Object.entries(groupedByCard).map(([cardId, assignments]) => {
|
||||||
|
const card = cards.find(c => c.id === Number(cardId));
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={cardId} className="hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CreditCard className="h-5 w-5 text-blue-600" />
|
||||||
|
{card.name}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600">{card.bank}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{assignments.map((assignment) => {
|
||||||
|
const category = categories.find(c => c.id === assignment.category_id);
|
||||||
|
if (!category) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={assignment.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Tag className="h-4 w-4 text-emerald-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{category.name}</p>
|
||||||
|
<Badge variant="secondary" className="mt-1">
|
||||||
|
{assignment.cashback_percent}% cashback
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDialog(assignment)}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(assignment.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<CardContent>
|
||||||
|
<Calendar className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No category assignments valid for {formatMonth(selectedMonth)}</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Assign cashback categories to your cards that will be valid during {formatMonth(selectedMonth)} to start tracking rewards.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Set Up Categories for {formatMonth(selectedMonth)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
328
src/pages/Transactions.tsx
Normal file
328
src/pages/Transactions.tsx
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Plus, Edit, Trash2, Receipt, CreditCard, Tag } from 'lucide-react';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog';
|
||||||
|
import { Input } from '../components/ui/input';
|
||||||
|
import { Label } from '../components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||||
|
import { Badge } from '../components/ui/badge';
|
||||||
|
import { transactionsAPI, cardsAPI, categoriesAPI } from '../lib/api';
|
||||||
|
import { formatCurrency, getCurrentMonth, formatMonth } from '../lib/utils';
|
||||||
|
import type { Transaction, Card as CardType, Category } from '../types';
|
||||||
|
|
||||||
|
export function Transactions() {
|
||||||
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [cards, setCards] = useState<CardType[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonth());
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
card_id: '',
|
||||||
|
category_id: '',
|
||||||
|
date: '',
|
||||||
|
amount: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [year, month] = selectedMonth.split('-').map(Number);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [transactionsData, cardsData, categoriesData] = await Promise.all([
|
||||||
|
transactionsAPI.getByMonth(year, month),
|
||||||
|
cardsAPI.getAll(),
|
||||||
|
categoriesAPI.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTransactions(transactionsData);
|
||||||
|
setCards(cardsData);
|
||||||
|
setCategories(categoriesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
card_id: Number(formData.card_id),
|
||||||
|
category_id: Number(formData.category_id),
|
||||||
|
date: formData.date,
|
||||||
|
amount: Number(formData.amount)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingTransaction) {
|
||||||
|
await transactionsAPI.update(editingTransaction.id, data);
|
||||||
|
} else {
|
||||||
|
await transactionsAPI.create(data);
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
handleCloseDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save transaction:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (confirm('Are you sure you want to delete this transaction?')) {
|
||||||
|
try {
|
||||||
|
await transactionsAPI.delete(id);
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete transaction:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDialog = (transaction?: Transaction) => {
|
||||||
|
if (transaction) {
|
||||||
|
setEditingTransaction(transaction);
|
||||||
|
setFormData({
|
||||||
|
card_id: transaction.card_id.toString(),
|
||||||
|
category_id: transaction.category_id.toString(),
|
||||||
|
date: transaction.date,
|
||||||
|
amount: transaction.amount.toString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setEditingTransaction(null);
|
||||||
|
setFormData({
|
||||||
|
card_id: '',
|
||||||
|
category_id: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
amount: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingTransaction(null);
|
||||||
|
setFormData({ card_id: '', category_id: '', date: '', amount: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3 mb-6"></div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-20 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSpent = transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||||
|
const totalCashback = transactions.reduce((sum, t) => sum + t.cashback_amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Transactions</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
type="month"
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(e) => setSelectedMonth(e.target.value)}
|
||||||
|
className="w-auto"
|
||||||
|
/>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Transaction
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingTransaction ? 'Edit Transaction' : 'Add New Transaction'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="card_id">Card</Label>
|
||||||
|
<Select value={formData.card_id} onValueChange={(value) => setFormData({ ...formData, card_id: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a card" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<SelectItem key={card.id} value={card.id.toString()}>
|
||||||
|
{card.name} ({card.bank})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category_id">Category</Label>
|
||||||
|
<Select value={formData.category_id} onValueChange={(value) => setFormData({ ...formData, category_id: value })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id.toString()}>
|
||||||
|
{category.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="date">Date</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="amount">Amount</Label>
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||||
|
placeholder="e.g., 25.99"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCloseDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
{editingTransaction ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card className="bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-0">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(totalSpent)}</div>
|
||||||
|
<p className="text-sm opacity-90">{formatMonth(selectedMonth)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-r from-blue-500 to-blue-600 text-white border-0">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Cashback</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(totalCashback)}</div>
|
||||||
|
<p className="text-sm opacity-90">{formatMonth(selectedMonth)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-r from-purple-500 to-purple-600 text-white border-0">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Transactions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{transactions.length}</div>
|
||||||
|
<p className="text-sm opacity-90">{formatMonth(selectedMonth)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions List */}
|
||||||
|
{transactions.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Transaction History - {formatMonth(selectedMonth)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="space-y-0">
|
||||||
|
{transactions.map((transaction) => (
|
||||||
|
<div key={transaction.id} className="flex items-center justify-between p-4 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Receipt className="h-5 w-5 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<CreditCard className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="font-medium">{transaction.card?.name}</span>
|
||||||
|
<Tag className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="text-sm text-gray-600">{transaction.category?.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(transaction.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium">{formatCurrency(transaction.amount)}</p>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
+{formatCurrency(transaction.cashback_amount)} cashback
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDialog(transaction)}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(transaction.id)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="text-center py-12">
|
||||||
|
<CardContent>
|
||||||
|
<Receipt className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No transactions for {formatMonth(selectedMonth)}</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Add your first transaction to start tracking cashback earnings.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenDialog()} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Your First Transaction
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
51
src/types/index.ts
Normal file
51
src/types/index.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
export interface Card {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
bank: string;
|
||||||
|
description: string;
|
||||||
|
image_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyCategory {
|
||||||
|
id: number;
|
||||||
|
card_id: number;
|
||||||
|
category_id: number;
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
cashback_percent: number;
|
||||||
|
card?: Card;
|
||||||
|
category?: Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: number;
|
||||||
|
card_id: number;
|
||||||
|
category_id: number;
|
||||||
|
date: string;
|
||||||
|
amount: number;
|
||||||
|
cashback_amount: number;
|
||||||
|
card?: Card;
|
||||||
|
category?: Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashbackSummary {
|
||||||
|
total_cashback: number;
|
||||||
|
total_spent: number;
|
||||||
|
card_summaries: {
|
||||||
|
card: Card;
|
||||||
|
total_cashback: number;
|
||||||
|
total_spent: number;
|
||||||
|
categories: {
|
||||||
|
category: Category;
|
||||||
|
cashback_percent: number;
|
||||||
|
cashback_amount: number;
|
||||||
|
spent_amount: number;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
77
tailwind.config.js
Normal file
77
tailwind.config.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
'./index.html'
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
26
vite.config.ts
Normal file
26
vite.config.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'lucide-react/dist/esm/icons/fingerprint.js': '/src/stub-empty.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['lucide-react'],
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user