Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions web/src/components/finance/AddIncomeModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 */}
<div
className="hidden md:block fixed inset-0 z-50 bg-black/40 backdrop-blur-sm animate-fade-in"
onClick={onClose}
/>

{/* Modal — full page on mobile, centered card on desktop */}
<div
role="dialog"
aria-modal="true"
aria-label="Add income"
className={cn(
'fixed z-[60] flex flex-col bg-[var(--bg-page)]',
'inset-0',
'md:inset-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2',
'md:w-full md:max-w-md md:max-h-[85vh] md:rounded-2xl md:border md:border-[var(--border)] md:shadow-2xl',
'animate-fade-in-up md:animate-scale-in'
)}
style={{ paddingTop: 'env(safe-area-inset-top)' }}
>
{/* Header */}
<div className="flex items-center justify-between h-14 px-4 flex-shrink-0 border-b border-[var(--border)]">
<h1 className="text-sm font-extrabold text-[var(--text-primary)] uppercase tracking-wider">
Add Income
</h1>
<button
onClick={onClose}
aria-label="Close"
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-input)] transition-colors"
>
<IconClose size={18} />
</button>
</div>

{/* Scrollable body */}
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-4 space-y-4">

{/* Amount + Date — side by side */}
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="income-amount" className={labelClass}>Amount</label>
<input
id="income-amount"
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="500.00"
className={cn(inputClass, 'placeholder:text-[var(--text-secondary)]')}
/>
</div>
<div>
<label htmlFor="income-date" className={labelClass}>Date</label>
<input
id="income-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className={inputClass}
/>
</div>
</div>

{/* Description / Source */}
<div>
<label htmlFor="income-description" className={labelClass}>Description / Source</label>
<input
id="income-description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Consulting payment — Acme Co."
className={cn(inputClass, 'placeholder:text-[var(--text-secondary)]')}
/>
</div>

{/* Category */}
<div>
<label htmlFor="income-category" className={labelClass}>
Category <span className="normal-case tracking-normal font-normal">(optional)</span>
</label>
<input
id="income-category"
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Manual Income"
className={cn(inputClass, 'placeholder:text-[var(--text-secondary)]')}
/>
</div>

{error && (
<div role="alert" className="px-3 py-2 rounded-lg bg-[var(--color-red)]/10 text-sm text-[var(--color-red)]">
{error}
</div>
)}
</div>
</div>

{/* Footer */}
<div
className="flex gap-3 px-4 py-3 border-t border-[var(--border)] flex-shrink-0 bg-[var(--bg-page)]"
style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }}
>
<button
onClick={onClose}
className="flex-1 h-10 rounded-lg border border-[var(--border)] text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-input)] transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!isValid || saving}
className="flex-1 h-10 rounded-lg bg-[var(--accent)] text-white text-sm font-semibold hover:bg-[var(--accent-dark)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{saving ? 'Saving...' : 'Add Income'}
</button>
</div>
</div>
</>
);
}
20 changes: 19 additions & 1 deletion web/src/hooks/useFirestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -294,6 +295,23 @@ export function useReceipts() {
return { receipts, loading };
}

export function useTransactions() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
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<TimeEntry[]>([]);
const [loading, setLoading] = useState(true);
Expand Down
64 changes: 63 additions & 1 deletion web/src/lib/finance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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>): WorkItem {
return {
Expand Down Expand Up @@ -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', () => {
Expand Down
30 changes: 26 additions & 4 deletions web/src/lib/finance.ts
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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' &&
Expand All @@ -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;
}

/**
Expand Down
Loading
Loading