diff --git a/web/src/components/finance/AddIncomeModal.tsx b/web/src/components/finance/AddIncomeModal.tsx new file mode 100644 index 0000000..cf06ef7 --- /dev/null +++ b/web/src/components/finance/AddIncomeModal.tsx @@ -0,0 +1,190 @@ +import { useState, useEffect } from 'react'; +import { createManualIncome } from '../../services/firestore'; +import { cn } from '../../lib/utils'; +import { IconClose } from '../icons'; + +interface AddIncomeModalProps { + onClose: () => void; + onAdded: (description: string) => void; +} + +function todayInputValue(): string { + return new Date().toISOString().split('T')[0]; +} + +export function AddIncomeModal({ onClose, onAdded }: AddIncomeModalProps) { + const [amount, setAmount] = useState(''); + const [date, setDate] = useState(() => todayInputValue()); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const amountValue = Number(amount); + const isValid = amount.trim() !== '' && amountValue > 0 && !!date && description.trim() !== ''; + + // Lock body scroll + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, []); + + // Close on Escape + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [onClose]); + + async function handleSave() { + if (!isValid || saving) return; + setSaving(true); + setError(null); + try { + await createManualIncome({ + description: description.trim(), + amount: amountValue, + date: new Date(`${date}T00:00:00`), + category: category.trim() || undefined, + }); + onAdded(description.trim()); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add income'); + setSaving(false); + } + } + + /* ── Shared input class (mirrors NewInvoiceModal) ── */ + const inputClass = 'w-full h-10 px-3 rounded-xl border border-[var(--border)] bg-[var(--bg-input)] text-sm text-[var(--text-primary)] outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/15 transition-all'; + const labelClass = 'block text-[10px] font-semibold text-[var(--text-secondary)] uppercase tracking-wider mb-1.5'; + + return ( + <> + {/* Desktop backdrop */} +
+ + {/* Modal — full page on mobile, centered card on desktop */} +
+ {/* Header */} +
+

+ Add Income +

+ +
+ + {/* Scrollable body */} +
+
+ + {/* Amount + Date — side by side */} +
+
+ + setAmount(e.target.value)} + placeholder="500.00" + className={cn(inputClass, 'placeholder:text-[var(--text-secondary)]')} + /> +
+
+ + setDate(e.target.value)} + className={inputClass} + /> +
+
+ + {/* Description / Source */} +
+ + setDescription(e.target.value)} + placeholder="Consulting payment — Acme Co." + className={cn(inputClass, 'placeholder:text-[var(--text-secondary)]')} + /> +
+ + {/* Category */} +
+ + setCategory(e.target.value)} + placeholder="Manual Income" + className={cn(inputClass, 'placeholder:text-[var(--text-secondary)]')} + /> +
+ + {error && ( +
+ {error} +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+ + ); +} diff --git a/web/src/hooks/useFirestore.ts b/web/src/hooks/useFirestore.ts index 26dff6b..5982d85 100644 --- a/web/src/hooks/useFirestore.ts +++ b/web/src/hooks/useFirestore.ts @@ -15,12 +15,13 @@ import { subscribeGitHubActivity, subscribeConnectedAccounts, subscribeReceipts, + subscribeTransactions, subscribeTimeEntries, subscribeMileageTrips, subscribeInsights, callGenerateInsights, } from '../services/firestore'; -import type { WorkItem, Quote, Client, AppSettings, App, Team, TeamMember, TeamInvite, IntegrationData, GitHubAccount, GitHubActivity, ConnectedAccount, Receipt, TimeEntry, MileageTrip, Insights } from '../lib/types'; +import type { WorkItem, Quote, Client, AppSettings, App, Team, TeamMember, TeamInvite, IntegrationData, GitHubAccount, GitHubActivity, ConnectedAccount, Receipt, Transaction, TimeEntry, MileageTrip, Insights } from '../lib/types'; /** * Wait for Firebase auth to be ready before subscribing to Firestore. @@ -294,6 +295,23 @@ export function useReceipts() { return { receipts, loading }; } +export function useTransactions() { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const unsubscribe = whenAuthReady(() => + subscribeTransactions((items) => { + setTransactions(items); + setLoading(false); + }), + ); + return unsubscribe; + }, []); + + return { transactions, loading }; +} + export function useTimeEntries() { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); diff --git a/web/src/lib/finance.test.ts b/web/src/lib/finance.test.ts index 6dfa1ae..8ab3bc2 100644 --- a/web/src/lib/finance.test.ts +++ b/web/src/lib/finance.test.ts @@ -11,7 +11,25 @@ import { getInvoiceStatusCounts, calculateTrend, } from './finance'; -import type { WorkItem, Client } from './types'; +import type { WorkItem, Client, Transaction } from './types'; + +function makeTransaction(overrides: Partial): Transaction { + return { + id: 'tx-' + Math.random().toString(36).slice(2), + ownerId: 'owner-1', + provider: 'manual', + date: new Date('2026-03-10'), + amount: 0, + description: 'Test income', + category: 'Manual Income', + type: 'income', + matchStatus: 'unmatched', + isManual: true, + createdAt: new Date('2026-03-10'), + updatedAt: new Date('2026-03-10'), + ...overrides, + } as Transaction; +} function makeWorkItem(overrides: Partial): WorkItem { return { @@ -78,6 +96,50 @@ describe('calculateRevenue', () => { const range = { start: new Date('2026-03-01'), end: new Date('2026-03-31') }; expect(calculateRevenue(items, range)).toBe(0); }); + it('includes in-range manual income alongside paid invoices', () => { + const items = [ + makeWorkItem({ totalCost: 1000, invoiceStatus: 'paid', invoicePaidDate: new Date('2026-03-05'), isBillable: true }), + ]; + const transactions = [ + makeTransaction({ amount: 250, date: new Date('2026-03-12') }), + makeTransaction({ amount: 100, date: new Date('2026-03-20') }), + ]; + const range = { start: new Date('2026-03-01'), end: new Date('2026-03-31') }; + expect(calculateRevenue(items, range, transactions)).toBe(1350); + }); + it('excludes manual income matched to an already-counted invoice', () => { + const transactions = [ + makeTransaction({ amount: 500, date: new Date('2026-03-10'), matchedWorkItemId: 'wi-1' }), + makeTransaction({ amount: 200, date: new Date('2026-03-10') }), + ]; + const range = { start: new Date('2026-03-01'), end: new Date('2026-03-31') }; + expect(calculateRevenue([], range, transactions)).toBe(200); + }); + it('excludes out-of-range manual income', () => { + const transactions = [ + makeTransaction({ amount: 300, date: new Date('2026-02-15') }), + makeTransaction({ amount: 400, date: new Date('2026-04-02') }), + makeTransaction({ amount: 150, date: new Date('2026-03-09') }), + ]; + const range = { start: new Date('2026-03-01'), end: new Date('2026-03-31') }; + expect(calculateRevenue([], range, transactions)).toBe(150); + }); + it('ignores non-manual or expense transactions', () => { + const transactions = [ + makeTransaction({ amount: 500, date: new Date('2026-03-10'), provider: 'plaid' }), + makeTransaction({ amount: 500, date: new Date('2026-03-10'), type: 'expense' }), + makeTransaction({ amount: 100, date: new Date('2026-03-10') }), + ]; + const range = { start: new Date('2026-03-01'), end: new Date('2026-03-31') }; + expect(calculateRevenue([], range, transactions)).toBe(100); + }); + it('is backward compatible when no transactions passed', () => { + const items = [ + makeWorkItem({ totalCost: 700, invoiceStatus: 'paid', invoicePaidDate: new Date('2026-03-05'), isBillable: true }), + ]; + const range = { start: new Date('2026-03-01'), end: new Date('2026-03-31') }; + expect(calculateRevenue(items, range)).toBe(700); + }); }); describe('calculateOutstanding', () => { diff --git a/web/src/lib/finance.ts b/web/src/lib/finance.ts index 580226b..ce2e61b 100644 --- a/web/src/lib/finance.ts +++ b/web/src/lib/finance.ts @@ -1,4 +1,4 @@ -import type { WorkItem, Client, WorkItemType } from './types'; +import type { WorkItem, Client, WorkItemType, Transaction } from './types'; import { isInvoice } from './workItem'; // ── Exported Types ────────────────────────────────────────────────────────── @@ -94,10 +94,18 @@ export function getDateRange(preset: DateRangePreset, ref: Date = new Date()): D /** * Sums totalCost of billable work items whose invoiceStatus is 'paid' - * and whose invoicePaidDate falls within range. + * and whose invoicePaidDate falls within range, PLUS manually-entered + * income transactions whose date falls within range. + * + * Manual income tied to an already-counted invoice (matchedWorkItemId set) + * is excluded to avoid double-counting. */ -export function calculateRevenue(items: readonly WorkItem[], range: DateRange): number { - return items.reduce((sum, item) => { +export function calculateRevenue( + items: readonly WorkItem[], + range: DateRange, + transactions: readonly Transaction[] = [] +): number { + const invoiceRevenue = items.reduce((sum, item) => { if ( item.isBillable && item.invoiceStatus === 'paid' && @@ -108,6 +116,20 @@ export function calculateRevenue(items: readonly WorkItem[], range: DateRange): } return sum; }, 0); + + const manualIncome = transactions.reduce((sum, tx) => { + if ( + tx.provider === 'manual' && + tx.type === 'income' && + !tx.matchedWorkItemId && + inRange(tx.date, range) + ) { + return sum + tx.amount; + } + return sum; + }, 0); + + return invoiceRevenue + manualIncome; } /** diff --git a/web/src/routes/contractor/Dashboard.tsx b/web/src/routes/contractor/Dashboard.tsx index 2bb51fe..b415052 100644 --- a/web/src/routes/contractor/Dashboard.tsx +++ b/web/src/routes/contractor/Dashboard.tsx @@ -4,7 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { WorkItemCard } from '../../components/workitems/WorkItemCard'; import { Onboarding } from '../../components/onboarding/Onboarding'; import { useAuth } from '../../hooks/useAuth'; -import { useSettings, useTimeEntries, useInsights } from '../../hooks/useFirestore'; +import { useSettings, useTimeEntries, useInsights, useTransactions } from '../../hooks/useFirestore'; import { isWorkOrder } from '../../lib/workItem'; import { UtilizationGauge } from '../../components/insights/UtilizationGauge'; import { InsightShimmer } from '../../components/insights/InsightShimmer'; @@ -79,6 +79,7 @@ export default function Dashboard({ workItems, clients, apps }: DashboardProps) const { user } = useAuth(); const { settings } = useSettings(user?.uid); const { entries: timeEntries } = useTimeEntries(); + const { transactions } = useTransactions(); const { insights, isGenerating } = useInsights(); const navigate = useNavigate(); const [onboardingDismissed, setOnboardingDismissed] = useState( @@ -121,7 +122,7 @@ export default function Dashboard({ workItems, clients, apps }: DashboardProps) .reduce((s, i) => s + i.totalHours, 0); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - const monthRevenue = workItems + const monthInvoiceRevenue = workItems .filter( (i) => i.isBillable && @@ -130,6 +131,18 @@ export default function Dashboard({ workItems, clients, apps }: DashboardProps) i.invoicePaidDate >= monthStart ) .reduce((s, i) => s + i.totalCost, 0); + // Manual income this month — exclude entries tied to an already-counted + // invoice (matchedWorkItemId) to avoid double-counting. + const monthManualIncome = transactions + .filter( + (t) => + t.provider === 'manual' && + t.type === 'income' && + !t.matchedWorkItemId && + t.date >= monthStart + ) + .reduce((s, t) => s + t.amount, 0); + const monthRevenue = monthInvoiceRevenue + monthManualIncome; const recentCompleted = workItems .filter((i) => i.status === 'approved' || i.status === 'completed') diff --git a/web/src/routes/contractor/FinanceOverview.tsx b/web/src/routes/contractor/FinanceOverview.tsx index ce87b37..382598e 100644 --- a/web/src/routes/contractor/FinanceOverview.tsx +++ b/web/src/routes/contractor/FinanceOverview.tsx @@ -15,7 +15,7 @@ import { KpiCard } from '../../components/finance/KpiCard'; import { RevenueChart } from '../../components/finance/RevenueChart'; import { TopClients } from '../../components/finance/TopClients'; import { ActivityFeed } from '../../components/finance/ActivityFeed'; -import { useInsights } from '../../hooks/useFirestore'; +import { useInsights, useTransactions } from '../../hooks/useFirestore'; import { CashFlowChart } from '../../components/insights/CashFlowChart'; import { RunwayCard } from '../../components/insights/RunwayCard'; import { InsightShimmer } from '../../components/insights/InsightShimmer'; @@ -35,8 +35,10 @@ export default function FinanceOverview({ workItems, clients }: { workItems: Wor return { start: prevStart, end: prevEnd }; }, [range]); - const revenue = useMemo(() => calculateRevenue(workItems, range), [workItems, range]); - const previousRevenue = useMemo(() => calculateRevenue(workItems, previousRange), [workItems, previousRange]); + const { transactions } = useTransactions(); + + const revenue = useMemo(() => calculateRevenue(workItems, range, transactions), [workItems, range, transactions]); + const previousRevenue = useMemo(() => calculateRevenue(workItems, previousRange, transactions), [workItems, previousRange, transactions]); const revenueTrend = useMemo(() => calculateTrend(revenue, previousRevenue), [revenue, previousRevenue]); const outstanding = useMemo(() => calculateOutstanding(workItems), [workItems]); diff --git a/web/src/routes/contractor/Transactions.tsx b/web/src/routes/contractor/Transactions.tsx index 76bf7a6..7198e7b 100644 --- a/web/src/routes/contractor/Transactions.tsx +++ b/web/src/routes/contractor/Transactions.tsx @@ -8,6 +8,7 @@ import { InsightBadge } from '../../components/insights/InsightBadge'; import { TransactionRow } from '../../components/finance/TransactionRow'; import { MatchSuggestion } from '../../components/finance/MatchSuggestion'; import { CsvImportModal } from '../../components/finance/CsvImportModal'; +import { AddIncomeModal } from '../../components/finance/AddIncomeModal'; import { formatDate } from '../../lib/utils'; import { db, functions } from '../../lib/firebase'; import { httpsCallable } from 'firebase/functions'; @@ -71,6 +72,7 @@ export default function Transactions() { const [smartSorting, setSmartSorting] = useState(false); const [smartSortResult, setSmartSortResult] = useState(null); const [showCsvImport, setShowCsvImport] = useState(false); + const [showAddIncome, setShowAddIncome] = useState(false); const [loadingMore, setLoadingMore] = useState(false); // Match suggestion state @@ -273,6 +275,15 @@ export default function Transactions() { )} +
)} + {/* Add Income Modal */} + {showAddIncome && ( + setShowAddIncome(false)} + onAdded={() => { + setSmartSortResult('Income added'); + fetchTransactions({ + pageSize: PAGE_SIZE, + accountId: filterAccountId || undefined, + type: filterType || undefined, + }).then((result) => { + setPage({ + transactions: result.transactions, + lastDoc: result.lastDoc, + hasMore: result.hasMore, + loading: false, + }); + }); + }} + /> + )} {/* CSV Import Modal */} {showCsvImport && ( { + const user = auth.currentUser; + if (!user) throw new Error('Not authenticated'); + + const docRef = await addDoc(collection(db, 'transactions'), { + ownerId: user.uid, + provider: 'manual', + externalId: null, + date: Timestamp.fromDate(data.date), + amount: Math.abs(data.amount), // Income is positive + description: data.description, + category: data.category?.trim() || 'Manual Income', + type: 'income', + matchStatus: 'unmatched', + isManual: true, + createdAt: Timestamp.now(), + updatedAt: Timestamp.now(), + }); + return docRef.id; +} + +export function subscribeTransactions( + callback: (transactions: Transaction[]) => void +): () => void { + const user = auth.currentUser; + if (!user) return () => {}; + + const q = query( + collection(db, 'transactions'), + where('ownerId', '==', user.uid) + ); + + return onSnapshot( + q, + (snapshot) => { + const transactions = snapshot.docs + .map((d) => docToTransaction(d.id, d.data())) + .sort((a, b) => b.date.getTime() - a.date.getTime()); + callback(transactions); + }, + (error) => { + console.error('transactions subscription error:', error); + callback([]); + } + ); +} + export async function confirmMatch(transactionId: string, workItemId: string): Promise { const user = auth.currentUser; if (!user) throw new Error('Not authenticated');