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