first commit

This commit is contained in:
anibilag 2025-08-28 23:04:25 +03:00
commit 2cc2cbb7a3
49 changed files with 11195 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

5
.bolt/prompt Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View 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
View File

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

21
server/Dockerfile Normal file
View 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

Binary file not shown.

73
server/database.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

18
server/package.json Normal file
View 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
View 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
View 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;

View 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;

View 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;

View 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
View 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
View 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>
);
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

77
tailwind.config.js Normal file
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View 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'],
},
});