Add account sharing, Get Paid schedule, recurring manager, overspend warnings, mobile nav
Account sharing: - fileStorage: add shares.json with createShare/listShares/getShareData/revokeShare - /api/share/account: real implementation using session token - /api/share/list: GET endpoint returning owned + received shares - /api/share/data: GET endpoint to fetch a shared account's data - SharedAccountManager: use session token, load existing shares, revoke support - AccountManagement: render SharedAccountManager per-account - page.tsx: load received shares on login, show "Shared with me" cards in Accounts view Get Paid schedule: - PaycheckSchedule type: weekly/biweekly/semimonthly/monthly with day config - computeNextPayday(): calculates next payday from today - Schedule UI in GetPaid modal with live "Next payday: ..." preview - Schedule persisted to localStorage template Recurring transaction manager: - Fix biweekly support (was missing) - Show "due now" vs "upcoming" with amber badge for overdue - Render in Transactions view; onApplyRecurring creates new transactions + updates lastAppliedDate Envelope overspending: - Red warning banner at top of Envelopes view listing overspent envelopes - Red ring on individual overspent envelope cards Mobile layout: - Fixed bottom tab bar (Home/Accounts/Txns/Envelopes/More) on sm: and below - Active tab highlighted in blue - Main content padded to avoid being hidden by bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
18fe6b5141
commit
5339e9818a
9 files changed
+508
-151
No files matched your search
@@ -1,32 +1,43 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { FileStorageService } from '@/lib/fileStorage';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = request.headers.get('x-user-id');
|
const token = request.headers.get('x-session-token');
|
||||||
const { accountId, email, role } = await request.json();
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
if (!userId || !accountId || !email) {
|
const session = await FileStorageService.validateSession(token);
|
||||||
return NextResponse.json(
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
{ error: 'Missing required fields' },
|
|
||||||
{ status: 400 }
|
const { accountId, email, role } = await request.json();
|
||||||
);
|
if (!accountId || !email || !role) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (email === session.email) {
|
||||||
|
return NextResponse.json({ error: 'Cannot share an account with yourself' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual sharing logic
|
const shareId = await FileStorageService.createShare(session.userId, session.email, email, accountId, role);
|
||||||
// 1. Validate email exists
|
return NextResponse.json({ success: true, shareId, message: `Account shared with ${email}` });
|
||||||
// 2. Create sharing record in database
|
|
||||||
// 3. Send notification to shared user
|
|
||||||
// 4. Add shared account to their account list
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Account shared with ${email} (${role} access)`,
|
|
||||||
note: 'Sharing feature coming soon - for now this is a placeholder',
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: error.message || 'Failed to share account' }, { status: 400 });
|
||||||
{ error: error.message || 'Failed to share account' },
|
}
|
||||||
{ status: 500 }
|
}
|
||||||
);
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = request.headers.get('x-session-token');
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const session = await FileStorageService.validateSession(token);
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { shareId } = await request.json();
|
||||||
|
if (!shareId) return NextResponse.json({ error: 'Missing shareId' }, { status: 400 });
|
||||||
|
|
||||||
|
await FileStorageService.revokeShare(shareId, session.userId);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ error: error.message || 'Failed to revoke share' }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { FileStorageService } from '@/lib/fileStorage';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = request.headers.get('x-session-token');
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const session = await FileStorageService.validateSession(token);
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const shareId = request.nextUrl.searchParams.get('shareId');
|
||||||
|
if (!shareId) return NextResponse.json({ error: 'Missing shareId' }, { status: 400 });
|
||||||
|
|
||||||
|
const data = await FileStorageService.getShareData(shareId, session.email);
|
||||||
|
if (!data) return NextResponse.json({ error: 'Share not found or access denied' }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ error: error.message || 'Failed to load shared data' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { FileStorageService } from '@/lib/fileStorage';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = request.headers.get('x-session-token');
|
||||||
|
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const session = await FileStorageService.validateSession(token);
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { received, owned } = await FileStorageService.listShares(session.email, session.userId);
|
||||||
|
return NextResponse.json({ received, owned });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ error: error.message || 'Failed to list shares' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-4
@@ -15,6 +15,8 @@ import Settings from '../components/Settings';
|
|||||||
import GetPaid from '../components/GetPaid';
|
import GetPaid from '../components/GetPaid';
|
||||||
import GoalsManager from '../components/GoalsManager';
|
import GoalsManager from '../components/GoalsManager';
|
||||||
import TransactionRulesManager from '../components/TransactionRulesManager';
|
import TransactionRulesManager from '../components/TransactionRulesManager';
|
||||||
|
import RecurringTransactionManager from '../components/RecurringTransactionManager';
|
||||||
|
import SharedAccountManager from '../components/SharedAccountManager';
|
||||||
|
|
||||||
interface User { userId: string; email: string }
|
interface User { userId: string; email: string }
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ export default function BudgetDashboard() {
|
|||||||
const [goals, setGoals] = useState<Goal[]>([]);
|
const [goals, setGoals] = useState<Goal[]>([]);
|
||||||
const [transactionRules, setTransactionRules] = useState<TransactionRule[]>([]);
|
const [transactionRules, setTransactionRules] = useState<TransactionRule[]>([]);
|
||||||
const [setupCompleted, setSetupCompleted] = useState(false);
|
const [setupCompleted, setSetupCompleted] = useState(false);
|
||||||
|
const [sharedAccounts, setSharedAccounts] = useState<Array<{ shareId: string; ownerEmail: string; account: Account; role: string }>>([]);
|
||||||
|
|
||||||
// Transaction filters
|
// Transaction filters
|
||||||
const [txSearch, setTxSearch] = useState('');
|
const [txSearch, setTxSearch] = useState('');
|
||||||
@@ -189,6 +192,24 @@ export default function BudgetDashboard() {
|
|||||||
|
|
||||||
// Auto-apply due recurring transactions
|
// Auto-apply due recurring transactions
|
||||||
autoApplyRecurring(txnsWithDates);
|
autoApplyRecurring(txnsWithDates);
|
||||||
|
|
||||||
|
// Load accounts shared with me
|
||||||
|
const sessionToken = AuthService.getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
fetch('/api/share/list', { headers: { 'x-session-token': sessionToken } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(async (data) => {
|
||||||
|
const received: any[] = data.received ?? [];
|
||||||
|
const loaded = await Promise.all(received.map(async (share: any) => {
|
||||||
|
const res = await fetch(`/api/share/data?shareId=${share.shareId}`, { headers: { 'x-session-token': sessionToken } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const d = await res.json();
|
||||||
|
return { shareId: share.shareId, ownerEmail: share.ownerEmail, account: d.account, role: share.role };
|
||||||
|
}));
|
||||||
|
setSharedAccounts(loaded.filter(Boolean) as any);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setShowSetupWizard(true);
|
setShowSetupWizard(true);
|
||||||
}
|
}
|
||||||
@@ -200,6 +221,7 @@ export default function BudgetDashboard() {
|
|||||||
setAccounts([]); setEnvelopes([]); setTransactions([]);
|
setAccounts([]); setEnvelopes([]); setTransactions([]);
|
||||||
setGoals([]); setTransactionRules([]);
|
setGoals([]); setTransactionRules([]);
|
||||||
setSetupCompleted(false); setShowSetupWizard(false);
|
setSetupCompleted(false); setShowSetupWizard(false);
|
||||||
|
setSharedAccounts([]);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@@ -269,6 +291,25 @@ export default function BudgetDashboard() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApplyRecurring = (dueTxns: Transaction[]) => {
|
||||||
|
const now = new Date();
|
||||||
|
const newTxns: Transaction[] = dueTxns.map(t => ({
|
||||||
|
...t,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: now,
|
||||||
|
lastAppliedDate: now.toISOString(),
|
||||||
|
}));
|
||||||
|
setTransactions(prev => {
|
||||||
|
const updated = prev.map(t =>
|
||||||
|
dueTxns.find(d => d.id === t.id) ? { ...t, lastAppliedDate: now.toISOString() } : t
|
||||||
|
);
|
||||||
|
const next = [...updated, ...newTxns];
|
||||||
|
setEnvelopes(calculateEnvelopeSpending(envelopes, next));
|
||||||
|
setAccounts(calculateAccountBalances(accounts, next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ── Event handlers ───────────────────────────────────────────────────────────
|
// ── Event handlers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleTransactionsAdded = (newTxns: Transaction[]) => {
|
const handleTransactionsAdded = (newTxns: Transaction[]) => {
|
||||||
@@ -778,6 +819,11 @@ export default function BudgetDashboard() {
|
|||||||
transactions={transactions}
|
transactions={transactions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RecurringTransactionManager
|
||||||
|
transactions={transactions}
|
||||||
|
onApplyRecurring={handleApplyRecurring}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="bg-white shadow rounded-lg p-4 mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -877,17 +923,34 @@ export default function BudgetDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderEnvelopes = () => (
|
const renderEnvelopes = () => {
|
||||||
|
const overspentEnvs = envelopes.filter(e => {
|
||||||
|
const incomeAlloc = transactions.filter(t => t.envelopeId === e.id && t.amount > 0).reduce((s, t) => s + t.amount, 0);
|
||||||
|
return e.allocated + incomeAlloc > 0 && e.spent > e.allocated + incomeAlloc;
|
||||||
|
});
|
||||||
|
return (
|
||||||
<div className="px-4 py-6 sm:px-0">
|
<div className="px-4 py-6 sm:px-0">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Envelopes</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Envelopes</h2>
|
||||||
|
{overspentEnvs.length > 0 && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-300 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<span className="text-red-500 text-xl flex-shrink-0">⚠️</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-red-800">Budget exceeded</p>
|
||||||
|
<p className="text-sm text-red-700 mt-0.5">
|
||||||
|
{overspentEnvs.map(e => e.name).join(', ')} {overspentEnvs.length === 1 ? 'is' : 'are'} over budget. Consider adjusting allocations or reviewing recent spending.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{envelopes.map(env => {
|
{envelopes.map(env => {
|
||||||
const incomeAlloc = transactions.filter(t => t.envelopeId === env.id && t.amount > 0).reduce((s, t) => s + t.amount, 0);
|
const incomeAlloc = transactions.filter(t => t.envelopeId === env.id && t.amount > 0).reduce((s, t) => s + t.amount, 0);
|
||||||
const totalAlloc = env.allocated + incomeAlloc;
|
const totalAlloc = env.allocated + incomeAlloc;
|
||||||
const remaining = totalAlloc - env.spent;
|
const remaining = totalAlloc - env.spent;
|
||||||
const pct = totalAlloc > 0 ? Math.min(100, Math.round((env.spent / totalAlloc) * 100)) : 0;
|
const pct = totalAlloc > 0 ? Math.min(100, Math.round((env.spent / totalAlloc) * 100)) : 0;
|
||||||
|
const isOver = totalAlloc > 0 && env.spent > totalAlloc;
|
||||||
return (
|
return (
|
||||||
<div key={env.id} className="bg-white shadow rounded-lg overflow-hidden">
|
<div key={env.id} className={`bg-white shadow rounded-lg overflow-hidden ${isOver ? 'ring-2 ring-red-400' : ''}`}>
|
||||||
<div className="h-1" style={{ backgroundColor: env.color }} />
|
<div className="h-1" style={{ backgroundColor: env.color }} />
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
@@ -921,7 +984,7 @@ export default function BudgetDashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
@@ -991,7 +1054,7 @@ export default function BudgetDashboard() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8 pb-20 sm:pb-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-20 text-gray-400">Loading…</div>
|
<div className="text-center py-20 text-gray-400">Loading…</div>
|
||||||
) : !user ? (
|
) : !user ? (
|
||||||
@@ -1056,6 +1119,24 @@ export default function BudgetDashboard() {
|
|||||||
onAccountUpdate={handleAccountUpdate}
|
onAccountUpdate={handleAccountUpdate}
|
||||||
onAccountDelete={id => setAccounts(prev => prev.filter(a => a.id !== id))}
|
onAccountDelete={id => setAccounts(prev => prev.filter(a => a.id !== id))}
|
||||||
/>
|
/>
|
||||||
|
{sharedAccounts.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-3">Shared with me</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sharedAccounts.map(sa => (
|
||||||
|
<div key={sa.shareId} className="bg-white shadow rounded-lg p-5 border border-blue-100">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="font-medium text-gray-900">{sa.account?.name}</span>
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded capitalize">{sa.role}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Shared by {sa.ownerEmail}</p>
|
||||||
|
<p className="text-xl font-bold text-blue-700">${(sa.account?.balance ?? 0).toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 capitalize">{sa.account?.type?.replace('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1128,6 +1209,25 @@ export default function BudgetDashboard() {
|
|||||||
accounts={accounts.map(a => ({ id: a.id, name: a.name }))}
|
accounts={accounts.map(a => ({ id: a.id, name: a.name }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Mobile bottom nav */}
|
||||||
|
{user && setupCompleted && (
|
||||||
|
<nav className="sm:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-40 flex">
|
||||||
|
{([
|
||||||
|
{ view: 'dashboard', icon: '🏠', label: 'Home' },
|
||||||
|
{ view: 'accounts', icon: '🏦', label: 'Accounts' },
|
||||||
|
{ view: 'transactions', icon: '↕', label: 'Txns' },
|
||||||
|
{ view: 'envelopes', icon: '📦', label: 'Envelopes' },
|
||||||
|
{ view: 'settings', icon: '⚙️', label: 'More' },
|
||||||
|
] as { view: ViewMode; icon: string; label: string }[]).map(({ view, icon, label }) => (
|
||||||
|
<button key={view} onClick={() => setCurrentView(view)}
|
||||||
|
className={`flex-1 flex flex-col items-center py-2 gap-0.5 text-xs transition-colors ${currentView === view ? 'text-blue-600' : 'text-gray-500'}`}>
|
||||||
|
<span className="text-lg leading-none">{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingEnvelope && (
|
{editingEnvelope && (
|
||||||
<EnvelopeEdit
|
<EnvelopeEdit
|
||||||
envelope={editingEnvelope}
|
envelope={editingEnvelope}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import SharedAccountManager from './SharedAccountManager';
|
||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -165,7 +166,8 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-gray-200">
|
||||||
{(accounts || []).map((account) => (
|
{(accounts || []).map((account) => (
|
||||||
<div key={account.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50" role="article" aria-label={`Account: ${account.name}`}>
|
<div key={account.id} className="px-6 py-4 hover:bg-gray-50" role="article" aria-label={`Account: ${account.name}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className={`w-4 h-4 rounded-full ${account.color}`} role="img" aria-label={`Color indicator: ${account.color}`}></div>
|
<div className={`w-4 h-4 rounded-full ${account.color}`} role="img" aria-label={`Color indicator: ${account.color}`}></div>
|
||||||
<div>
|
<div>
|
||||||
@@ -211,6 +213,8 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SharedAccountManager accountId={account.id} accountName={account.name} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+97
-11
@@ -35,12 +35,20 @@ interface Transaction {
|
|||||||
type DistributionMethod = 'proportional' | 'equal' | 'custom';
|
type DistributionMethod = 'proportional' | 'equal' | 'custom';
|
||||||
type AllocationMap = Record<string, number>;
|
type AllocationMap = Record<string, number>;
|
||||||
|
|
||||||
|
interface PaycheckSchedule {
|
||||||
|
frequency: 'weekly' | 'biweekly' | 'semimonthly' | 'monthly';
|
||||||
|
dayOfWeek?: number; // 0-6, for weekly/biweekly
|
||||||
|
dayOfMonth?: number; // 1-31, for monthly/semimonthly
|
||||||
|
dayOfMonth2?: number; // for semimonthly second date
|
||||||
|
}
|
||||||
|
|
||||||
interface PaycheckTemplate {
|
interface PaycheckTemplate {
|
||||||
amount: number;
|
amount: number;
|
||||||
selectedAccountId: string;
|
selectedAccountId: string;
|
||||||
description: string;
|
description: string;
|
||||||
distributionMethod: DistributionMethod;
|
distributionMethod: DistributionMethod;
|
||||||
customAllocations: AllocationMap;
|
customAllocations: AllocationMap;
|
||||||
|
schedule?: PaycheckSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GetPaidProps {
|
interface GetPaidProps {
|
||||||
@@ -70,6 +78,8 @@ export default function GetPaid({
|
|||||||
const [defaultAmountInput, setDefaultAmountInput] = useState('');
|
const [defaultAmountInput, setDefaultAmountInput] = useState('');
|
||||||
const [saveTemplate, setSaveTemplate] = useState(false);
|
const [saveTemplate, setSaveTemplate] = useState(false);
|
||||||
const [savedTemplate, setSavedTemplate] = useState<PaycheckTemplate | null>(null);
|
const [savedTemplate, setSavedTemplate] = useState<PaycheckTemplate | null>(null);
|
||||||
|
const [showSchedule, setShowSchedule] = useState(false);
|
||||||
|
const [schedule, setSchedule] = useState<PaycheckSchedule>({ frequency: 'biweekly', dayOfWeek: 5 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -122,12 +132,37 @@ export default function GetPaid({
|
|||||||
|
|
||||||
const applySavedTemplate = () => {
|
const applySavedTemplate = () => {
|
||||||
if (!savedTemplate) return;
|
if (!savedTemplate) return;
|
||||||
|
|
||||||
setAmount(savedTemplate.amount?.toString() || '');
|
setAmount(savedTemplate.amount?.toString() || '');
|
||||||
setSelectedAccountId(savedTemplate.selectedAccountId || '');
|
setSelectedAccountId(savedTemplate.selectedAccountId || '');
|
||||||
setDescription(savedTemplate.description || 'Paycheck');
|
setDescription(savedTemplate.description || 'Paycheck');
|
||||||
setDistributionMethod(savedTemplate.distributionMethod || 'proportional');
|
setDistributionMethod(savedTemplate.distributionMethod || 'proportional');
|
||||||
setCustomAllocations(savedTemplate.customAllocations || {});
|
setCustomAllocations(savedTemplate.customAllocations || {});
|
||||||
|
if (savedTemplate.schedule) setSchedule(savedTemplate.schedule);
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeNextPayday = (sched: PaycheckSchedule): Date => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const next = new Date(today);
|
||||||
|
|
||||||
|
if (sched.frequency === 'weekly' || sched.frequency === 'biweekly') {
|
||||||
|
const dow = sched.dayOfWeek ?? 5;
|
||||||
|
const diff = (dow - today.getDay() + 7) % 7 || (sched.frequency === 'biweekly' ? 14 : 7);
|
||||||
|
next.setDate(today.getDate() + diff);
|
||||||
|
} else if (sched.frequency === 'semimonthly') {
|
||||||
|
const d1 = sched.dayOfMonth ?? 1;
|
||||||
|
const d2 = sched.dayOfMonth2 ?? 15;
|
||||||
|
const thisMonthD1 = new Date(today.getFullYear(), today.getMonth(), d1);
|
||||||
|
const thisMonthD2 = new Date(today.getFullYear(), today.getMonth(), d2);
|
||||||
|
const nextMonthD1 = new Date(today.getFullYear(), today.getMonth() + 1, d1);
|
||||||
|
const candidates = [thisMonthD1, thisMonthD2, nextMonthD1].filter(d => d > today);
|
||||||
|
return candidates.sort((a, b) => a.getTime() - b.getTime())[0] ?? nextMonthD1;
|
||||||
|
} else {
|
||||||
|
const dom = sched.dayOfMonth ?? 1;
|
||||||
|
next.setDate(dom);
|
||||||
|
if (next <= today) next.setMonth(next.getMonth() + 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (event: React.FormEvent) => {
|
const handleSubmit = (event: React.FormEvent) => {
|
||||||
@@ -183,6 +218,7 @@ export default function GetPaid({
|
|||||||
description,
|
description,
|
||||||
distributionMethod,
|
distributionMethod,
|
||||||
customAllocations: previewAllocations,
|
customAllocations: previewAllocations,
|
||||||
|
schedule: showSchedule ? schedule : undefined,
|
||||||
};
|
};
|
||||||
localStorage.setItem('paycheckTemplate', JSON.stringify(template));
|
localStorage.setItem('paycheckTemplate', JSON.stringify(template));
|
||||||
setSavedTemplate(template);
|
setSavedTemplate(template);
|
||||||
@@ -384,17 +420,67 @@ export default function GetPaid({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="space-y-3">
|
||||||
<input
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
type="checkbox"
|
<input type="checkbox" id="saveTemplate" checked={saveTemplate} onChange={e => setSaveTemplate(e.target.checked)} className="rounded" />
|
||||||
id="saveTemplate"
|
<span className="text-sm text-gray-700">Save this information to use next time</span>
|
||||||
checked={saveTemplate}
|
|
||||||
onChange={(event) => setSaveTemplate(event.target.checked)}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<label htmlFor="saveTemplate" className="text-sm text-gray-700">
|
|
||||||
Save this information to use next time
|
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showSchedule} onChange={e => setShowSchedule(e.target.checked)} className="rounded" />
|
||||||
|
<span className="text-sm text-gray-700">Set a paycheck schedule (shows next payday)</span>
|
||||||
|
</label>
|
||||||
|
{showSchedule && (
|
||||||
|
<div className="ml-6 p-3 bg-green-50 border border-green-200 rounded-lg space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
|
||||||
|
<select value={schedule.frequency} onChange={e => setSchedule(s => ({ ...s, frequency: e.target.value as PaycheckSchedule['frequency'] }))}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900">
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="biweekly">Bi-weekly (every 2 weeks)</option>
|
||||||
|
<option value="semimonthly">Semi-monthly (twice a month)</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(schedule.frequency === 'weekly' || schedule.frequency === 'biweekly') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Payday</label>
|
||||||
|
<select value={schedule.dayOfWeek ?? 5} onChange={e => setSchedule(s => ({ ...s, dayOfWeek: parseInt(e.target.value) }))}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900">
|
||||||
|
{['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'].map((d, i) => (
|
||||||
|
<option key={i} value={i}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schedule.frequency === 'semimonthly' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">First payday</label>
|
||||||
|
<input type="number" min={1} max={31} value={schedule.dayOfMonth ?? 1}
|
||||||
|
onChange={e => setSchedule(s => ({ ...s, dayOfMonth: parseInt(e.target.value) }))}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Second payday</label>
|
||||||
|
<input type="number" min={1} max={31} value={schedule.dayOfMonth2 ?? 15}
|
||||||
|
onChange={e => setSchedule(s => ({ ...s, dayOfMonth2: parseInt(e.target.value) }))}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schedule.frequency === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Day of month</label>
|
||||||
|
<input type="number" min={1} max={31} value={schedule.dayOfMonth ?? 1}
|
||||||
|
onChange={e => setSchedule(s => ({ ...s, dayOfMonth: parseInt(e.target.value) }))}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-900" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-green-800 font-medium">
|
||||||
|
Next payday: {computeNextPayday(schedule).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedAccountId && accountEnvelopes.length > 0 && (
|
{selectedAccountId && accountEnvelopes.length > 0 && (
|
||||||
|
|||||||
@@ -2,97 +2,117 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface RecurringTransactionManagerProps {
|
interface RecurringTx {
|
||||||
transactions: Array<{
|
|
||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
accountId: string;
|
||||||
|
envelopeId?: string;
|
||||||
|
date: Date;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
recurringFrequency?: 'weekly' | 'monthly' | 'yearly';
|
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
|
||||||
lastAppliedDate?: string;
|
lastAppliedDate?: string;
|
||||||
}>;
|
}
|
||||||
onApplyRecurring: (transactions: any[]) => void;
|
|
||||||
|
interface RecurringTransactionManagerProps {
|
||||||
|
transactions: RecurringTx[];
|
||||||
|
onApplyRecurring: (due: RecurringTx[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDate(tx: RecurringTx): Date | null {
|
||||||
|
const last = tx.lastAppliedDate ? new Date(tx.lastAppliedDate) : (tx.date instanceof Date ? tx.date : new Date(tx.date));
|
||||||
|
const next = new Date(last);
|
||||||
|
switch (tx.recurringFrequency) {
|
||||||
|
case 'weekly': next.setDate(next.getDate() + 7); break;
|
||||||
|
case 'biweekly': next.setDate(next.getDate() + 14); break;
|
||||||
|
case 'monthly': next.setMonth(next.getMonth() + 1); break;
|
||||||
|
case 'yearly': next.setFullYear(next.getFullYear() + 1); break;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecurringTransactionManager({ transactions, onApplyRecurring }: RecurringTransactionManagerProps) {
|
export default function RecurringTransactionManager({ transactions, onApplyRecurring }: RecurringTransactionManagerProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const getNextRecurringDate = (lastDate: string | undefined, frequency: string | undefined): Date | null => {
|
const recurring = transactions.filter(t => t.isRecurring && t.recurringFrequency);
|
||||||
if (!lastDate || !frequency) return null;
|
|
||||||
const last = new Date(lastDate);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
switch (frequency) {
|
const due = recurring.filter(t => {
|
||||||
case 'weekly':
|
const n = nextDate(t);
|
||||||
const nextWeek = new Date(last);
|
return n && n <= now;
|
||||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
});
|
||||||
return nextWeek <= now ? nextWeek : null;
|
|
||||||
case 'monthly':
|
|
||||||
const nextMonth = new Date(last);
|
|
||||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
|
||||||
return nextMonth <= now ? nextMonth : null;
|
|
||||||
case 'yearly':
|
|
||||||
const nextYear = new Date(last);
|
|
||||||
nextYear.setFullYear(nextYear.getFullYear() + 1);
|
|
||||||
return nextYear <= now ? nextYear : null;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const recurringTransactions = transactions.filter(t => t.isRecurring);
|
const upcoming = recurring.filter(t => {
|
||||||
const upcomingTransactions = recurringTransactions.filter(t => getNextRecurringDate(t.lastAppliedDate, t.recurringFrequency));
|
const n = nextDate(t);
|
||||||
|
return n && n > now;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const na = nextDate(a)!.getTime();
|
||||||
|
const nb = nextDate(b)!.getTime();
|
||||||
|
return na - nb;
|
||||||
|
});
|
||||||
|
|
||||||
if (recurringTransactions.length === 0) {
|
if (recurring.length === 0) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className={`border rounded-lg p-4 mb-6 ${due.length > 0 ? 'bg-amber-50 border-amber-300' : 'bg-blue-50 border-blue-200'}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="w-full flex justify-between items-center font-semibold text-blue-900 hover:text-blue-700"
|
className="w-full flex justify-between items-center font-semibold text-gray-900 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<span>🔄 Recurring Transactions ({recurringTransactions.length})</span>
|
<span className="flex items-center gap-2">
|
||||||
<span>{expanded ? '▲' : '▼'}</span>
|
<span>↻ Recurring Transactions</span>
|
||||||
|
{due.length > 0 && (
|
||||||
|
<span className="px-2 py-0.5 bg-amber-500 text-white text-xs rounded-full">{due.length} due</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-sm">{expanded ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-4">
|
||||||
<p className="text-sm text-blue-800">
|
{due.length > 0 && (
|
||||||
You have <strong>{upcomingTransactions.length}</strong> recurring transaction(s) that are due to be applied.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{upcomingTransactions.map(tx => (
|
|
||||||
<div key={tx.id} className="bg-white p-3 rounded border border-blue-100">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{tx.description}</p>
|
<p className="text-sm font-medium text-amber-900 mb-2">Due now ({due.length})</p>
|
||||||
<p className="text-xs text-gray-500">
|
<div className="space-y-2">
|
||||||
Frequency: <span className="capitalize font-semibold">{tx.recurringFrequency}</span>
|
{due.map(tx => (
|
||||||
</p>
|
<div key={tx.id} className="bg-white border border-amber-200 rounded-lg p-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 text-sm">{tx.description}</p>
|
||||||
|
<p className="text-xs text-gray-500 capitalize">{tx.recurringFrequency} · was due {nextDate(tx)?.toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={`font-semibold ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
<p className={`font-semibold text-sm ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
${Math.abs(tx.amount).toFixed(2)}
|
{tx.amount >= 0 ? '+' : ''}${Math.abs(tx.amount).toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
{upcomingTransactions.length > 0 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => onApplyRecurring(due)}
|
||||||
const newTransactions = upcomingTransactions.map(tx => ({
|
className="w-full mt-3 bg-amber-600 text-white py-2 px-4 rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium"
|
||||||
...tx,
|
|
||||||
lastAppliedDate: new Date().toISOString().split('T')[0],
|
|
||||||
}));
|
|
||||||
onApplyRecurring(newTransactions);
|
|
||||||
setExpanded(false);
|
|
||||||
}}
|
|
||||||
className="w-full mt-3 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
>
|
||||||
Apply Now
|
Apply {due.length} overdue transaction{due.length > 1 ? 's' : ''} now
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-900 mb-2">Upcoming</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{upcoming.slice(0, 5).map(tx => (
|
||||||
|
<div key={tx.id} className="bg-white border border-blue-100 rounded-lg p-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 text-sm">{tx.description}</p>
|
||||||
|
<p className="text-xs text-gray-500 capitalize">{tx.recurringFrequency} · next: {nextDate(tx)?.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<p className={`font-semibold text-sm ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{tx.amount >= 0 ? '+' : ''}${Math.abs(tx.amount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,61 +1,91 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AuthService } from '../lib/authService';
|
||||||
|
|
||||||
|
interface SharedUser {
|
||||||
|
shareId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SharedAccountManagerProps {
|
interface SharedAccountManagerProps {
|
||||||
userId: string;
|
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SharedAccountManager({ userId, accountId, accountName }: SharedAccountManagerProps) {
|
export default function SharedAccountManager({ accountId, accountName }: SharedAccountManagerProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [shareEmail, setShareEmail] = useState('');
|
const [shareEmail, setShareEmail] = useState('');
|
||||||
const [shareRole, setShareRole] = useState<'view' | 'edit'>('view');
|
const [shareRole, setShareRole] = useState<'view' | 'edit'>('view');
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
const [sharedUsers, setSharedUsers] = useState<Array<{ email: string; role: string }>>([]);
|
const [sharedUsers, setSharedUsers] = useState<SharedUser[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingShares, setLoadingShares] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) return;
|
||||||
|
setLoadingShares(true);
|
||||||
|
const token = AuthService.getSessionToken();
|
||||||
|
fetch('/api/share/list', { headers: { 'x-session-token': token ?? '' } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const forThisAccount: SharedUser[] = (data.owned ?? [])
|
||||||
|
.filter((s: any) => s.accountId === accountId)
|
||||||
|
.map((s: any) => ({ shareId: s.shareId, email: s.targetEmail, role: s.role }));
|
||||||
|
setSharedUsers(forThisAccount);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoadingShares(false));
|
||||||
|
}, [expanded, accountId]);
|
||||||
|
|
||||||
const handleShareAccount = async (e: React.FormEvent) => {
|
const handleShareAccount = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const token = AuthService.getSessionToken();
|
||||||
const response = await fetch('/api/share/account', {
|
const response = await fetch('/api/share/account', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-user-id': userId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ accountId, email: shareEmail, role: shareRole }),
|
body: JSON.stringify({ accountId, email: shareEmail, role: shareRole }),
|
||||||
});
|
});
|
||||||
|
const result = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error(result.error || 'Failed to share account');
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to share account');
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage({ type: 'success', text: `Account shared with ${shareEmail}` });
|
setMessage({ type: 'success', text: `Account shared with ${shareEmail}` });
|
||||||
|
setSharedUsers(prev => [...prev, { shareId: result.shareId, email: shareEmail, role: shareRole }]);
|
||||||
setShareEmail('');
|
setShareEmail('');
|
||||||
setSharedUsers([...sharedUsers, { email: shareEmail, role: shareRole }]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage({
|
setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to share account' });
|
||||||
type: 'error',
|
|
||||||
text: error instanceof Error ? error.message : 'Failed to share account',
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (shareId: string) => {
|
||||||
|
if (!confirm('Remove this person\'s access?')) return;
|
||||||
|
try {
|
||||||
|
const token = AuthService.getSessionToken();
|
||||||
|
const response = await fetch('/api/share/account', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
|
||||||
|
body: JSON.stringify({ shareId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to revoke');
|
||||||
|
setSharedUsers(prev => prev.filter(u => u.shareId !== shareId));
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to remove access' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="flex justify-between items-center w-full font-semibold text-gray-900 hover:text-gray-700"
|
className="flex justify-between items-center w-full font-semibold text-gray-900 hover:text-gray-700 text-sm"
|
||||||
>
|
>
|
||||||
<span>👥 Share Account</span>
|
<span>👥 Share "{accountName}"</span>
|
||||||
<span>{expanded ? '▲' : '▼'}</span>
|
<span>{expanded ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -63,71 +93,65 @@ export default function SharedAccountManager({ userId, accountId, accountName }:
|
|||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<form onSubmit={handleShareAccount} className="space-y-3">
|
<form onSubmit={handleShareAccount} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={shareEmail}
|
value={shareEmail}
|
||||||
onChange={(e) => setShareEmail(e.target.value)}
|
onChange={(e) => setShareEmail(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 text-sm"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">Permission</label>
|
||||||
Permission Level
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={shareRole}
|
value={shareRole}
|
||||||
onChange={(e) => setShareRole(e.target.value as 'view' | 'edit')}
|
onChange={(e) => setShareRole(e.target.value as 'view' | 'edit')}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 text-sm"
|
||||||
>
|
>
|
||||||
<option value="view">View Only</option>
|
<option value="view">View Only</option>
|
||||||
<option value="edit">Can Edit</option>
|
<option value="edit">Can Edit</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !shareEmail}
|
disabled={loading || !shareEmail}
|
||||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm"
|
||||||
>
|
>
|
||||||
{loading ? 'Sharing...' : 'Share Account'}
|
{loading ? 'Sharing…' : 'Share Account'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<div className={`p-3 rounded-md text-sm ${message.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
|
||||||
className={`p-3 rounded-md text-sm ${
|
|
||||||
message.type === 'success'
|
|
||||||
? 'bg-green-50 text-green-800 border border-green-200'
|
|
||||||
: 'bg-red-50 text-red-800 border border-red-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{message.text}
|
{message.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sharedUsers.length > 0 && (
|
{loadingShares ? (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
<p className="text-sm text-gray-400">Loading…</p>
|
||||||
<p className="text-sm font-medium text-gray-900 mb-2">Shared With:</p>
|
) : sharedUsers.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 mb-2">Shared with:</p>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sharedUsers.map((user) => (
|
{sharedUsers.map((u) => (
|
||||||
<li key={user.email} className="flex justify-between items-center text-sm">
|
<li key={u.shareId} className="flex justify-between items-center text-sm bg-gray-50 px-3 py-2 rounded">
|
||||||
<span className="text-gray-700">{user.email}</span>
|
<div>
|
||||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded capitalize">
|
<span className="text-gray-800">{u.email}</span>
|
||||||
{user.role}
|
<span className="ml-2 text-xs bg-gray-200 px-1.5 py-0.5 rounded capitalize">{u.role}</span>
|
||||||
</span>
|
</div>
|
||||||
|
<button onClick={() => handleRevoke(u.shareId)} className="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">Not shared with anyone yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
|
<p className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||||
💡 <strong>Note:</strong> Shared accounts will appear in the recipient's Capsule app. They can view or edit based on the permissions you set.
|
The recipient must already have a Capsule account. They'll see this account in their "Shared with me" section.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+74
-1
@@ -7,15 +7,27 @@ const DATA_DIR = path.join(process.cwd(), 'data');
|
|||||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
||||||
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
|
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
|
||||||
|
const SHARES_FILE = path.join(DATA_DIR, 'shares.json');
|
||||||
|
|
||||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
|
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
interface Share {
|
||||||
|
ownerUserId: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
targetEmail: string;
|
||||||
|
accountId: string;
|
||||||
|
role: 'view' | 'edit';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shares { [shareId: string]: Share }
|
||||||
|
|
||||||
async function initializeDirectories() {
|
async function initializeDirectories() {
|
||||||
try {
|
try {
|
||||||
await fsPromises.mkdir(DATA_DIR, { recursive: true });
|
await fsPromises.mkdir(DATA_DIR, { recursive: true });
|
||||||
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE]) {
|
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE]) {
|
||||||
try {
|
try {
|
||||||
await fsPromises.access(file);
|
await fsPromises.access(file);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -268,6 +280,67 @@ export class FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async createShare(ownerUserId: string, ownerEmail: string, targetEmail: string, accountId: string, role: 'view' | 'edit'): Promise<string> {
|
||||||
|
const users = await readJson<Users>(USERS_FILE);
|
||||||
|
if (!Object.values(users).some(u => u.email === targetEmail)) {
|
||||||
|
throw new Error('No Capsule account found for that email address');
|
||||||
|
}
|
||||||
|
const releaseLock = await FileLock.acquire('shares-file');
|
||||||
|
try {
|
||||||
|
const shares = await readJson<Shares>(SHARES_FILE);
|
||||||
|
// Prevent duplicate shares for same account+email
|
||||||
|
const existing = Object.entries(shares).find(
|
||||||
|
([, s]) => s.ownerUserId === ownerUserId && s.accountId === accountId && s.targetEmail === targetEmail
|
||||||
|
);
|
||||||
|
if (existing) return existing[0];
|
||||||
|
const shareId = crypto.randomUUID();
|
||||||
|
shares[shareId] = { ownerUserId, ownerEmail, targetEmail, accountId, role, createdAt: new Date().toISOString() };
|
||||||
|
await writeJson(SHARES_FILE, shares);
|
||||||
|
return shareId;
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listShares(email: string, userId: string): Promise<{ received: Array<Share & { shareId: string }>; owned: Array<Share & { shareId: string }> }> {
|
||||||
|
const shares = await readJson<Shares>(SHARES_FILE);
|
||||||
|
const received = Object.entries(shares)
|
||||||
|
.filter(([, s]) => s.targetEmail === email)
|
||||||
|
.map(([shareId, s]) => ({ shareId, ...s }));
|
||||||
|
const owned = Object.entries(shares)
|
||||||
|
.filter(([, s]) => s.ownerUserId === userId)
|
||||||
|
.map(([shareId, s]) => ({ shareId, ...s }));
|
||||||
|
return { received, owned };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getShareData(shareId: string, requestorEmail: string): Promise<{ account: any; envelopes: any[]; transactions: any[] } | null> {
|
||||||
|
const shares = await readJson<Shares>(SHARES_FILE);
|
||||||
|
const share = shares[shareId];
|
||||||
|
if (!share || share.targetEmail !== requestorEmail) return null;
|
||||||
|
const data = await this.loadUserData(share.ownerUserId);
|
||||||
|
const account = data.accounts?.find((a: any) => a.id === share.accountId);
|
||||||
|
if (!account) return null;
|
||||||
|
const envelopes = (data.envelopes || []).filter((e: any) => e.accountId === share.accountId);
|
||||||
|
const transactions = (data.transactions || []).filter((t: any) => t.accountId === share.accountId);
|
||||||
|
return { account, envelopes, transactions };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async revokeShare(shareId: string, requestorUserId: string): Promise<void> {
|
||||||
|
const releaseLock = await FileLock.acquire('shares-file');
|
||||||
|
try {
|
||||||
|
const shares = await readJson<Shares>(SHARES_FILE);
|
||||||
|
const share = shares[shareId];
|
||||||
|
if (!share) return;
|
||||||
|
if (share.ownerUserId !== requestorUserId && share.targetEmail !== requestorUserId) {
|
||||||
|
throw new Error('Not authorized to revoke this share');
|
||||||
|
}
|
||||||
|
delete shares[shareId];
|
||||||
|
await writeJson(SHARES_FILE, shares);
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async _createSession(userId: string, email: string): Promise<string> {
|
private static async _createSession(userId: string, email: string): Promise<string> {
|
||||||
const releaseLock = await FileLock.acquire('sessions-file');
|
const releaseLock = await FileLock.acquire('sessions-file');
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user