From 7c96088625c1f18d229fa0b715f13d92ea3d58d6 Mon Sep 17 00:00:00 2001 From: Dennis Wang <66754085+denniwang@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:28:02 -0400 Subject: [PATCH 1/3] expenditures page v1 --- apps/frontend/next.config.ts | 15 ++- apps/frontend/src/app/expenses/page.tsx | 166 ++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/app/expenses/page.tsx diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index e9ffa308..e69bdea8 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -1,7 +1,18 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + async rewrites() { + return [ + { + source: '/api/expenditures/:path*', + destination: 'http://localhost:3004/expenditures/:path*', + }, + { + source: '/api/expenditures', + destination: 'http://localhost:3004/expenditures', + }, + ]; + }, }; export default nextConfig; diff --git a/apps/frontend/src/app/expenses/page.tsx b/apps/frontend/src/app/expenses/page.tsx new file mode 100644 index 00000000..cfb8b4dc --- /dev/null +++ b/apps/frontend/src/app/expenses/page.tsx @@ -0,0 +1,166 @@ +'use client'; +import { Input, Table } from '@chakra-ui/react'; +import Header from '../components/Header'; +import { ReactNode, useEffect, useState } from 'react'; + +type Expenditure = { + expenditure_id: number; + project_id: number; + entered_by: number | null; + amount: string; + category: string | null; + description: string | null; + spent_on: string; + created_at: string | null; +}; + +function ColumnHeader({ + children, + ...rest +}: { children: ReactNode } & React.ThHTMLAttributes) { + return ( + + {children} + + ); +} + +type SortKey = 'expenditure_id' | 'spent_on' | 'description' | 'amount' | 'category'; +type SortOrder = 'asc' | 'desc'; + +export default function ExpensePage() { + const [query, setQuery] = useState(''); + const [expenditures, setExpenditures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sortKey, setSortKey] = useState('spent_on'); + const [sortOrder, setSortOrder] = useState('desc'); + + useEffect(() => { + async function fetchExpenditures() { + try { + const res = await fetch('/api/expenditures', { + headers: { + Authorization: `Bearer ${localStorage.getItem('token') ?? ''}`, + }, + }); + if (!res.ok) { + throw new Error(`Failed to fetch expenditures: ${res.status}`); + } + const data: Expenditure[] = await res.json(); + setExpenditures(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load expenditures'); + } finally { + setLoading(false); + } + } + fetchExpenditures(); + }, []); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortOrder('asc'); + } + }; + + const sortedData = [...expenditures] + .filter( + (e) => + e.expenditure_id.toString().includes(query.toLowerCase()) || + (e.description ?? '').toLowerCase().includes(query.toLowerCase()) || + (e.category ?? '').toLowerCase().includes(query.toLowerCase()), + ) + .sort((a, b) => { + const aVal = a[sortKey]; + const bVal = b[sortKey]; + + if (sortKey === 'amount') { + const diff = parseFloat(String(aVal)) - parseFloat(String(bVal)); + return sortOrder === 'asc' ? diff : -diff; + } + + return sortOrder === 'asc' + ? String(aVal ?? '').localeCompare(String(bVal ?? '')) + : String(bVal ?? '').localeCompare(String(aVal ?? '')); + }); + + const sortIndicator = (key: SortKey) => + sortKey === key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : ''; + + return ( + <> +
+ +
+

Expenses

+ + setQuery(e.target.value)} + value={query} + className="!mb-4 !max-w-md !rounded !border !border-black-200 !px-3 !py-2 !font-body" + /> + + {loading &&

Loading expenditures...

} + {error &&

{error}

} + + {!loading && !error && ( + + + + handleSort('expenditure_id')}> + Expense ID{sortIndicator('expenditure_id')} + + handleSort('spent_on')}> + Date{sortIndicator('spent_on')} + + handleSort('description')}> + Description{sortIndicator('description')} + + handleSort('category')}> + Category{sortIndicator('category')} + + handleSort('amount')}> + Amount{sortIndicator('amount')} + + + + + {sortedData.length === 0 ? ( + + + No expenditures found. + + + ) : ( + sortedData.map((e) => ( + + #{e.expenditure_id} + + {new Date(e.spent_on).toLocaleDateString()} + + {e.description ?? '—'} + {e.category ?? '—'} + + ${parseFloat(e.amount).toFixed(2)} + + + )) + )} + + + )} +
+ + ); +} From d3ac828faebcecf6ba3ac19300860a6fa35a80d5 Mon Sep 17 00:00:00 2001 From: Dennis Wang <66754085+denniwang@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:33:19 -0400 Subject: [PATCH 2/3] pagination, add expense modal, figma design --- apps/frontend/next.config.ts | 16 +- .../src/app/components/AddExpenseModal.tsx | 297 ++++++++++++ .../src/app/components/Pagination.tsx | 72 +++ apps/frontend/src/app/expenses/page.tsx | 444 +++++++++++++----- 4 files changed, 700 insertions(+), 129 deletions(-) create mode 100644 apps/frontend/src/app/components/AddExpenseModal.tsx create mode 100644 apps/frontend/src/app/components/Pagination.tsx diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index e69bdea8..45d300bd 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -4,13 +4,25 @@ const nextConfig: NextConfig = { async rewrites() { return [ { - source: '/api/expenditures/:path*', + source: '/auth/:path*', + destination: 'http://localhost:3006/auth/:path*', + }, + { + source: '/expenditures/:path*', destination: 'http://localhost:3004/expenditures/:path*', }, { - source: '/api/expenditures', + source: '/expenditures', destination: 'http://localhost:3004/expenditures', }, + { + source: '/projects/:path*', + destination: 'http://localhost:3002/projects/:path*', + }, + { + source: '/projects', + destination: 'http://localhost:3002/projects', + }, ]; }, }; diff --git a/apps/frontend/src/app/components/AddExpenseModal.tsx b/apps/frontend/src/app/components/AddExpenseModal.tsx new file mode 100644 index 00000000..efb9fb97 --- /dev/null +++ b/apps/frontend/src/app/components/AddExpenseModal.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; +import DropdownSelector from './DropdownSelector'; +import { apiFetch } from '@/lib/api'; + +interface Project { + project_id: number; + name: string; +} + +interface AddExpenseModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + token: string; + categories: string[]; + projects: Project[]; +} + +export default function AddExpenseModal({ + open, + onClose, + onSuccess, + token, + categories, + projects, +}: AddExpenseModalProps) { + const [newDate, setNewDate] = useState(''); + const [newType, setNewType] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newAmount, setNewAmount] = useState(''); + const [newProject, setNewProject] = useState(''); + + const [dateError, setDateError] = useState(false); + const [typeError, setTypeError] = useState(false); + const [descError, setDescError] = useState(false); + const [amountError, setAmountError] = useState(false); + const [projectError, setProjectError] = useState(false); + const [submitError, setSubmitError] = useState(null); + + function resetForm() { + setNewDate(''); + setNewType(''); + setNewDescription(''); + setNewAmount(''); + setNewProject(''); + setDateError(false); + setTypeError(false); + setDescError(false); + setAmountError(false); + setProjectError(false); + setSubmitError(null); + } + + function handleClose() { + resetForm(); + onClose(); + } + + async function handleSubmit() { + const hasDateError = !newDate.trim(); + const hasTypeError = !newType.trim(); + const hasDescError = !newDescription.trim(); + const hasAmountError = !newAmount.trim() || isNaN(Number(newAmount)) || Number(newAmount) < 0; + const hasProjectError = !newProject.trim(); + + setDateError(hasDateError); + setTypeError(hasTypeError); + setDescError(hasDescError); + setAmountError(hasAmountError); + setProjectError(hasProjectError); + + if (hasDateError || hasTypeError || hasDescError || hasAmountError || hasProjectError) return; + + const selectedProject = projects.find((p) => p.name === newProject); + if (!selectedProject) { + setProjectError(true); + return; + } + + try { + await apiFetch('/expenditures', { + method: 'POST', + token, + body: JSON.stringify({ + projectID: selectedProject.project_id, + amount: Number(newAmount), + category: newType, + description: newDescription, + spentOn: newDate, + }), + }); + + resetForm(); + onSuccess(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create expense'); + } + } + + return ( + { if (!e.open) handleClose(); }}> + + + + + + + Add New Expense + + + + + + {/* Date */} +
+ + { + setNewDate(e.target.value); + setDateError(false); + }} + style={{ + border: `1px solid ${dateError ? 'var(--color-error-red)' : '#CBD5E0'}`, + borderRadius: '6px', + padding: '8px 12px', + fontSize: '14px', + outline: 'none', + width: '100%', + fontFamily: 'inherit', + color: newDate ? 'inherit' : '#A0AEC0', + }} + /> + {dateError && ( + + Select a date + + )} +
+ + {/* Type of Expense */} +
+ + { + setNewType(val as string); + setTypeError(false); + }} + /> + {typeError && ( + + Select a type of expense + + )} +
+ + {/* Description */} +
+ +