From d7d7dd0dd6e58dd3695d54501d86a57ff71378a5 Mon Sep 17 00:00:00 2001 From: Nick Reeder Date: Tue, 24 Feb 2026 09:52:41 -0800 Subject: [PATCH] Add TeamExpense application code --- backend/src/db.ts | 29 +++++ backend/src/index.ts | 23 ++++ backend/src/middleware/auth.ts | 32 +++++ backend/src/routes/auth.ts | 66 ++++++++++ backend/src/routes/expenses.ts | 152 ++++++++++++++++++++++++ backend/src/routes/reports.ts | 56 +++++++++ backend/src/utils/validate.ts | 22 ++++ frontend/src/App.tsx | 28 +++++ frontend/src/api/client.ts | 15 +++ frontend/src/components/ExpenseCard.tsx | 38 ++++++ frontend/src/components/StatusBadge.tsx | 33 +++++ frontend/src/index.tsx | 10 ++ frontend/src/pages/Dashboard.tsx | 56 +++++++++ frontend/src/pages/ExpenseForm.tsx | 69 +++++++++++ frontend/src/pages/ExpenseList.tsx | 67 +++++++++++ frontend/src/pages/Login.tsx | 52 ++++++++ frontend/src/pages/Reports.tsx | 70 +++++++++++ 17 files changed, 818 insertions(+) create mode 100644 backend/src/db.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/expenses.ts create mode 100644 backend/src/routes/reports.ts create mode 100644 backend/src/utils/validate.ts create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/ExpenseCard.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/ExpenseForm.tsx create mode 100644 frontend/src/pages/ExpenseList.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Reports.tsx diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..2e6440b --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,29 @@ +import Database from "better-sqlite3"; +import path from "path"; + +const db = new Database(path.join(__dirname, "..", "teamexpense.db")); + +db.pragma("journal_mode = WAL"); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member' + ); + + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); +`); + +export default db; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..6e13ba7 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,23 @@ +import express from "express"; +import cors from "cors"; +import authRoutes from "./routes/auth"; +import expenseRoutes from "./routes/expenses"; +import reportRoutes from "./routes/reports"; + +const app = express(); +const PORT = 3001; + +app.use(cors()); +app.use(express.json()); + +app.use("/api/auth", authRoutes); +app.use("/api/expenses", expenseRoutes); +app.use("/api/reports", reportRoutes); + +app.get("/api/health", (_, res) => { + res.json({ status: "ok" }); +}); + +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..f8983f5 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = "secret123"; + +export interface AuthRequest extends Request { + user?: { id: number; email: string; role: string }; +} + +export function authenticate(req: AuthRequest, res: Response, next: NextFunction) { + const header = req.headers.authorization; + + if (!header || !header.startsWith("Bearer ")) { + return res.status(401).json({ error: "No token provided" }); + } + + const token = header.split(" ")[1]; + + try { + const decoded = jwt.verify(token, JWT_SECRET) as { + id: number; + email: string; + role: string; + }; + req.user = decoded; + next(); + } catch { + return res.status(401).json({ error: "Invalid token" }); + } +} + +export { JWT_SECRET }; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..6b20eed --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,66 @@ +import { Router, Request, Response } from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import db from "../db"; +import { JWT_SECRET } from "../middleware/auth"; +import { isValidEmail } from "../utils/validate"; + +const router = Router(); + +router.post("/register", async (req: Request, res: Response) => { + const { email, password, name } = req.body; + + if (!email || !password || !name) { + return res.status(400).json({ error: "All fields are required" }); + } + + if (!isValidEmail(email)) { + return res.status(400).json({ error: "Invalid email format" }); + } + + const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); + if (existing) { + return res.status(409).json({ error: "Email already registered" }); + } + + const hash = await bcrypt.hash(password, 10); + const result = db.prepare( + "INSERT INTO users (email, password, name) VALUES (?, ?, ?)" + ).run(email, hash, name); + + const token = jwt.sign( + { id: result.lastInsertRowid, email, role: "member" }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.status(201).json({ token, user: { id: result.lastInsertRowid, email, name, role: "member" } }); +}); + +router.post("/login", async (req: Request, res: Response) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: "Email and password are required" }); + } + + const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any; + if (!user) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + const valid = await bcrypt.compare(password, user.password); + if (!valid) { + return res.status(401).json({ error: "Invalid credentials" }); + } + + const token = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + JWT_SECRET, + { expiresIn: "24h" } + ); + + res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role } }); +}); + +export default router; diff --git a/backend/src/routes/expenses.ts b/backend/src/routes/expenses.ts new file mode 100644 index 0000000..4bda695 --- /dev/null +++ b/backend/src/routes/expenses.ts @@ -0,0 +1,152 @@ +import { Router, Response } from "express"; +import db from "../db"; +import { authenticate, AuthRequest } from "../middleware/auth"; +import { isPositiveNumber, sanitizeString, CATEGORIES, BUDGET_LIMIT } from "../utils/validate"; + +const router = Router(); + +// Create expense +router.post("/", authenticate, (req: AuthRequest, res: Response) => { + const { amount, description, category } = req.body; + const userId = req.user!.id; + + if (!isPositiveNumber(amount)) { + return res.status(400).json({ error: "Amount must be a positive number" }); + } + + if (!description || !category) { + return res.status(400).json({ error: "Description and category are required" }); + } + + if (!CATEGORIES.includes(category)) { + return res.status(400).json({ error: "Invalid category" }); + } + + const existing = db.prepare( + "SELECT COALESCE(SUM(amount), 0) as total FROM expenses WHERE user_id = ? AND status != 'rejected'" + ).get(userId) as any; + + if (existing.total + amount > BUDGET_LIMIT) { + return res.status(400).json({ error: `Would exceed budget limit of $${BUDGET_LIMIT}` }); + } + + const result = db.prepare( + "INSERT INTO expenses (user_id, amount, description, category) VALUES (?, ?, ?, ?)" + ).run(userId, amount, sanitizeString(description), category); + + res.status(201).json({ id: result.lastInsertRowid, amount, description, category, status: "pending" }); +}); + +// Get single expense +router.get("/:id", authenticate, (req: AuthRequest, res: Response) => { + const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id); + + if (!expense) { + return res.status(404).json({ error: "Expense not found" }); + } + + res.json(expense); +}); + +// List expenses with pagination +router.get("/", authenticate, (req: AuthRequest, res: Response) => { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const userId = req.user!.id; + + const expenses = db.prepare( + "SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?" + ).all(userId, limit, page * limit); + + const count = db.prepare( + "SELECT COUNT(*) as total FROM expenses WHERE user_id = ?" + ).get(userId) as any; + + res.json({ data: expenses, total: count.total, page, limit }); +}); + +// Search expenses +router.get("/search", authenticate, (req: AuthRequest, res: Response) => { + const query = req.query.q as string; + + if (!query) { + return res.status(400).json({ error: "Search query is required" }); + } + + const results = db.prepare( + `SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC` + ).all(); + + res.json(results); +}); + +// Filter expenses by date range +router.get("/filter", authenticate, (req: AuthRequest, res: Response) => { + const { startDate, endDate, category } = req.query; + const userId = req.user!.id; + + let sql = "SELECT * FROM expenses WHERE user_id = ?"; + const params: any[] = [userId]; + + if (startDate) { + sql += " AND created_at >= ?"; + params.push(startDate); + } + + if (endDate) { + sql += " AND created_at < ?"; + params.push(endDate); + } + + if (category) { + sql += " AND category = ?"; + params.push(category); + } + + sql += " ORDER BY created_at DESC"; + const results = db.prepare(sql).all(...params); + res.json(results); +}); + +// Approve / reject expense (managers only) +router.patch("/:id/status", authenticate, (req: AuthRequest, res: Response) => { + if (req.user!.role !== "admin") { + return res.status(403).json({ error: "Only managers can approve/reject expenses" }); + } + + const { status } = req.body; + if (!["approved", "rejected"].includes(status)) { + return res.status(400).json({ error: "Status must be 'approved' or 'rejected'" }); + } + + const result = db.prepare( + "UPDATE expenses SET status = ? WHERE id = ?" + ).run(status, req.params.id); + + if (result.changes === 0) { + return res.status(404).json({ error: "Expense not found" }); + } + + res.json({ message: `Expense ${status}` }); +}); + +// Delete expense +router.delete("/:id", authenticate, (req: AuthRequest, res: Response) => { + const expense = db.prepare("SELECT * FROM expenses WHERE id = ? AND user_id = ?").get( + req.params.id, + req.user!.id + ) as any; + + if (!expense) { + return res.status(404).json({ error: "Expense not found" }); + } + + if (expense.status !== "pending") { + return res.status(400).json({ error: "Can only delete pending expenses" }); + } + + db.prepare("DELETE FROM expenses WHERE id = ?").run(req.params.id); + res.json({ message: "Expense deleted" }); +}); + +export default router; diff --git a/backend/src/routes/reports.ts b/backend/src/routes/reports.ts new file mode 100644 index 0000000..68c0e9f --- /dev/null +++ b/backend/src/routes/reports.ts @@ -0,0 +1,56 @@ +import { Router, Request, Response } from "express"; +import db from "../db"; +import { authenticate, AuthRequest } from "../middleware/auth"; + +const router = Router(); + +// User spending summary +router.get("/my-summary", authenticate, (req: AuthRequest, res: Response) => { + const userId = req.user!.id; + + const expenses = db.prepare( + "SELECT amount, category FROM expenses WHERE user_id = ? AND status = 'approved'" + ).all(userId) as any[]; + + const byCategory: Record = {}; + let total = 0; + + for (const exp of expenses) { + byCategory[exp.category] = (byCategory[exp.category] || 0) + exp.amount; + total += exp.amount; + } + + res.json({ total, byCategory, count: expenses.length }); +}); + +// Team summary — admin only +router.get("/team-summary", (req: Request, res: Response) => { + const expenses = db.prepare( + "SELECT * FROM expenses WHERE status = 'approved'" + ).all() as any[]; + + const summary: Record = {}; + + for (const exp of expenses) { + if (!summary[exp.user_id]) { + const user = db.prepare("SELECT name FROM users WHERE id = ?").get(exp.user_id) as any; + summary[exp.user_id] = { name: user.name, total: 0, count: 0 }; + } + summary[exp.user_id].total += exp.amount; + summary[exp.user_id].count += 1; + } + + res.json(Object.values(summary)); +}); + +// Export all expenses +router.get("/export", authenticate, (req: AuthRequest, res: Response) => { + if (req.user!.role !== "admin") { + return res.status(403).json({ error: "Admin only" }); + } + + const allExpenses = db.prepare("SELECT * FROM expenses").all(); + res.json(allExpenses); +}); + +export default router; diff --git a/backend/src/utils/validate.ts b/backend/src/utils/validate.ts new file mode 100644 index 0000000..09e77e4 --- /dev/null +++ b/backend/src/utils/validate.ts @@ -0,0 +1,22 @@ +export function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export function isPositiveNumber(value: unknown): value is number { + return typeof value === "number" && value > 0; +} + +export function sanitizeString(input: string): string { + return input.trim().slice(0, 500); +} + +export const CATEGORIES = [ + "travel", + "meals", + "supplies", + "software", + "equipment", + "other", +] as const; + +export const BUDGET_LIMIT = 5000; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..af4a253 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import ExpenseList from "./pages/ExpenseList"; +import ExpenseForm from "./pages/ExpenseForm"; +import Reports from "./pages/Reports"; + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const token = localStorage.getItem("token"); + return token ? <>{children} : ; +} + +export default function App() { + return ( + +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..e735dcb --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,15 @@ +import axios from "axios"; + +const api = axios.create({ + baseURL: "http://localhost:3001/api", +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +export default api; diff --git a/frontend/src/components/ExpenseCard.tsx b/frontend/src/components/ExpenseCard.tsx new file mode 100644 index 0000000..7241ed2 --- /dev/null +++ b/frontend/src/components/ExpenseCard.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import StatusBadge from "./StatusBadge"; + +interface Props { + expense: { + id: number; + amount: number; + description: string; + category: string; + status: string; + created_at: string; + }; +} + +export default function ExpenseCard({ expense }: Props) { + return ( +
+
+
+ + ${expense.amount.toFixed(2)} +
+ +
+

{expense.description}

+ + {expense.category} · {new Date(expense.created_at).toLocaleDateString()} + +
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..3f84190 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +const STATUS_STYLES: Record = { + pending: { + color: "#F5E642", + backgroundColor: "#FFFFFF", + border: "1px solid #F5E642", + padding: "2px 8px", + borderRadius: 4, + fontSize: 12, + fontWeight: 600, + }, + approved: { + color: "#FFFFFF", + backgroundColor: "#22C55E", + padding: "2px 8px", + borderRadius: 4, + fontSize: 12, + fontWeight: 600, + }, + rejected: { + color: "#FFFFFF", + backgroundColor: "#EF4444", + padding: "2px 8px", + borderRadius: 4, + fontSize: 12, + fontWeight: 600, + }, +}; + +export default function StatusBadge({ status }: { status: string }) { + return {status}; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..e8ad281 --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); +root.render( + + + +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..a0f36a2 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,56 @@ +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import api from "../api/client"; + +interface Summary { + total: number; + byCategory: Record; + count: number; +} + +export default function Dashboard() { + const [summary, setSummary] = useState(null); + const user = JSON.parse(localStorage.getItem("user") || "{}"); + + useEffect(() => { + const fetchSummary = async () => { + try { + const { data } = await api.get("/reports/my-summary"); + setSummary(data); + } catch (err) { + console.error("Failed to fetch summary", err); + } + }; + + fetchSummary(); + setInterval(fetchSummary, 30000); + }, []); + + return ( +
+

Welcome, {user.name}

+ + + {summary && ( +
+

My Spending Summary

+

Total approved: ${summary.total.toFixed(2)}

+

Expense count: {summary.count}

+ +

By Category

+
    + {Object.entries(summary.byCategory).map(([cat, amount]) => ( +
  • + {cat}: ${(amount as number).toFixed(2)} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/ExpenseForm.tsx b/frontend/src/pages/ExpenseForm.tsx new file mode 100644 index 0000000..588f49b --- /dev/null +++ b/frontend/src/pages/ExpenseForm.tsx @@ -0,0 +1,69 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "../api/client"; + +const CATEGORIES = ["travel", "meals", "supplies", "software", "equipment", "other"]; + +export default function ExpenseForm() { + const [amount, setAmount] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState("travel"); + const [error, setError] = useState(""); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await api.post("/expenses", { + amount: parseFloat(amount), + description, + category, + }); + navigate("/expenses"); + } catch (err: any) { + setError(err.response?.data?.error || "Failed to submit"); + } + }; + + return ( +
+

New Expense

+ {error &&

{error}

} +
+
+ setAmount(e.target.value)} + placeholder="Amount ($)" + style={{ width: "100%", padding: 8 }} + /> +
+
+ setDescription(e.target.value)} + placeholder="Description" + style={{ width: "100%", padding: 8 }} + /> +
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/pages/ExpenseList.tsx b/frontend/src/pages/ExpenseList.tsx new file mode 100644 index 0000000..ea4bbb7 --- /dev/null +++ b/frontend/src/pages/ExpenseList.tsx @@ -0,0 +1,67 @@ +import React, { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import api from "../api/client"; +import ExpenseCard from "../components/ExpenseCard"; + +interface Expense { + id: number; + amount: number; + description: string; + category: string; + status: string; + created_at: string; +} + +export default function ExpenseList() { + const [expenses, setExpenses] = useState([]); + const [page, setPage] = useState(1); + + useEffect(() => { + const fetch = async () => { + const { data } = await api.get(`/expenses?page=${page}`); + setExpenses(data.data); + }; + fetch(); + }, [page]); + + const handleDelete = async (id: number) => { + try { + await api.delete(`/expenses/${id}`); + setExpenses(expenses.filter((e) => e.id !== id)); + } catch (err: any) { + alert(err.response?.data?.error || "Delete failed"); + } + }; + + return ( +
+

My Expenses

+ + New Expense + +
+ {expenses.map((exp) => ( +
+ +
handleDelete(exp.id)} + style={{ + marginLeft: 12, + color: "red", + cursor: "pointer", + padding: "4px 8px", + }} + > + Delete +
+
+ ))} +
+ +
+ + Page {page} + +
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..b27aa7b --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,52 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "../api/client"; + +export default function Login() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const { data } = await api.post("/auth/login", { email, password }); + localStorage.setItem("token", data.token); + localStorage.setItem("user", JSON.stringify(data.user)); + navigate("/"); + } catch (err: any) { + setError(err.response?.data?.error || "Login failed"); + } + }; + + return ( +
+

TeamExpense

+ {error &&

{error}

} +
+
+ setEmail(e.target.value)} + placeholder="Email" + style={{ width: "100%", padding: 8 }} + /> +
+
+ setPassword(e.target.value)} + placeholder="Password" + style={{ width: "100%", padding: 8 }} + /> +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx new file mode 100644 index 0000000..52caec5 --- /dev/null +++ b/frontend/src/pages/Reports.tsx @@ -0,0 +1,70 @@ +import React, { useState, useEffect } from "react"; +import api from "../api/client"; + +interface TeamMember { + name: string; + total: number; + count: number; +} + +export default function Reports() { + const [team, setTeam] = useState([]); + const [error, setError] = useState(""); + const user = JSON.parse(localStorage.getItem("user") || "{}"); + + useEffect(() => { + if (user.role === "admin") { + api.get("/reports/team-summary") + .then(({ data }) => setTeam(data)) + .catch(() => setError("Failed to load team report")); + } + }, [user.role]); + + const handleExport = async () => { + try { + const { data } = await api.get("/reports/export"); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "expenses-export.json"; + a.click(); + } catch { + alert("Export failed"); + } + }; + + return ( +
+

Reports

+ {error &&

{error}

} + + {user.role === "admin" && ( + <> +

Team Spending

+ + + + + + + + + + {team.map((m) => ( + + + + + + ))} + +
NameTotalCount
{m.name}${m.total.toFixed(2)}{m.count}
+ + + )} +
+ ); +}