Initial commit: SubSplit - subscription split tracker
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/98940979-c541-43a6-922d-fc7326ba5c85
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en" className="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SubSplit - Multi-Family Shared Subscription Splitter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body className="h-full bg-slate-100/80 text-slate-900 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "SubSplit",
|
||||
"description": "Track shared family and multi-group subscriptions, calculate exact individual dues, and balance split settlements effortlessly.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
Generated
+3912
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^6.2.3",
|
||||
"express": "^4.21.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"motion": "^12.23.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8080;
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`SubSplit running at http://localhost:${PORT}`);
|
||||
});
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Group,
|
||||
Member,
|
||||
Subscription,
|
||||
OneOffExpense,
|
||||
SettlementRecord,
|
||||
MemberBalance,
|
||||
DebtTransaction,
|
||||
} from './types';
|
||||
import {
|
||||
INITIAL_GROUPS,
|
||||
INITIAL_MEMBERS,
|
||||
INITIAL_SUBSCRIPTIONS,
|
||||
INITIAL_ONE_OFF_EXPENSES,
|
||||
INITIAL_SETTLEMENTS,
|
||||
} from './data/initialData';
|
||||
import { calculateAllMemberBalances, calculateSimplifiedDebts, getMonthlyCost } from './utils/splitCalculator';
|
||||
import { Header } from './components/Header';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { SubscriptionList } from './components/SubscriptionList';
|
||||
import { SubscriptionModal } from './components/SubscriptionModal';
|
||||
import { OneOffExpenseList } from './components/OneOffExpenseList';
|
||||
import { OneOffExpenseModal } from './components/OneOffExpenseModal';
|
||||
import { GroupMemberManager } from './components/GroupMemberManager';
|
||||
import { SettlementModal } from './components/SettlementModal';
|
||||
import { SettlementLog } from './components/SettlementLog';
|
||||
import { BreakdownModal } from './components/BreakdownModal';
|
||||
import { MemberBreakdownModal } from './components/MemberBreakdownModal';
|
||||
import { ShareReportModal } from './components/ShareReportModal';
|
||||
|
||||
export default function App() {
|
||||
// Persistence Keys
|
||||
const STORAGE_KEY_GROUPS = 'subsplit_groups_v1';
|
||||
const STORAGE_KEY_MEMBERS = 'subsplit_members_v1';
|
||||
const STORAGE_KEY_SUBS = 'subsplit_subs_v1';
|
||||
const STORAGE_KEY_ONE_OFF = 'subsplit_one_off_v1';
|
||||
const STORAGE_KEY_SETTLEMENTS = 'subsplit_settlements_v1';
|
||||
|
||||
// State
|
||||
const [groups, setGroups] = useState<Group[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_GROUPS);
|
||||
return saved ? JSON.parse(saved) : INITIAL_GROUPS;
|
||||
});
|
||||
|
||||
const [members, setMembers] = useState<Member[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_MEMBERS);
|
||||
return saved ? JSON.parse(saved) : INITIAL_MEMBERS;
|
||||
});
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_SUBS);
|
||||
return saved ? JSON.parse(saved) : INITIAL_SUBSCRIPTIONS;
|
||||
});
|
||||
|
||||
const [oneOffExpenses, setOneOffExpenses] = useState<OneOffExpense[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_ONE_OFF);
|
||||
return saved ? JSON.parse(saved) : INITIAL_ONE_OFF_EXPENSES;
|
||||
});
|
||||
|
||||
const [settlements, setSettlements] = useState<SettlementRecord[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_SETTLEMENTS);
|
||||
return saved ? JSON.parse(saved) : INITIAL_SETTLEMENTS;
|
||||
});
|
||||
|
||||
// Save to LocalStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_GROUPS, JSON.stringify(groups));
|
||||
}, [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_MEMBERS, JSON.stringify(members));
|
||||
}, [members]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_SUBS, JSON.stringify(subscriptions));
|
||||
}, [subscriptions]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_ONE_OFF, JSON.stringify(oneOffExpenses));
|
||||
}, [oneOffExpenses]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY_SETTLEMENTS, JSON.stringify(settlements));
|
||||
}, [settlements]);
|
||||
|
||||
// Tab & Modal Navigation
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'subscriptions' | 'one-off' | 'members' | 'settlements'>('dashboard');
|
||||
|
||||
// Modal States
|
||||
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
|
||||
const [editingSub, setEditingSub] = useState<Subscription | null>(null);
|
||||
|
||||
const [isOneOffModalOpen, setIsOneOffModalOpen] = useState(false);
|
||||
const [editingOneOff, setEditingOneOff] = useState<OneOffExpense | null>(null);
|
||||
|
||||
const [isSettleModalOpen, setIsSettleModalOpen] = useState(false);
|
||||
const [settleFromId, setSettleFromId] = useState<string | undefined>();
|
||||
const [settleToId, setSettleToId] = useState<string | undefined>();
|
||||
const [settleDefaultAmount, setSettleDefaultAmount] = useState<number | undefined>();
|
||||
|
||||
const [breakdownSub, setBreakdownSub] = useState<Subscription | null>(null);
|
||||
const [selectedMemberBalance, setSelectedMemberBalance] = useState<MemberBalance | null>(null);
|
||||
const [isShareReportOpen, setIsShareReportOpen] = useState(false);
|
||||
|
||||
// Computed Balances & Debts
|
||||
const balances: MemberBalance[] = calculateAllMemberBalances(subscriptions, oneOffExpenses, members, groups, settlements);
|
||||
const debts: DebtTransaction[] = calculateSimplifiedDebts(balances);
|
||||
|
||||
const activeSubs = subscriptions.filter((s) => s.status === 'active');
|
||||
const totalMonthlyCost = activeSubs.reduce(
|
||||
(acc, s) => acc + getMonthlyCost(s.cost, s.billingCycle),
|
||||
0
|
||||
);
|
||||
|
||||
// Handlers for Subscriptions
|
||||
const handleSaveSub = (sub: Subscription) => {
|
||||
const exists = subscriptions.some((s) => s.id === sub.id);
|
||||
if (exists) {
|
||||
setSubscriptions(subscriptions.map((s) => (s.id === sub.id ? sub : s)));
|
||||
} else {
|
||||
setSubscriptions([...subscriptions, sub]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSub = (subId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this subscription?')) {
|
||||
setSubscriptions(subscriptions.filter((s) => s.id !== subId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSubStatus = (subId: string) => {
|
||||
setSubscriptions(
|
||||
subscriptions.map((s) =>
|
||||
s.id === subId ? { ...s, status: s.status === 'active' ? 'paused' : 'active' } : s
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Handlers for One-Off Expenses
|
||||
const handleSaveOneOff = (expense: OneOffExpense) => {
|
||||
const exists = oneOffExpenses.some((e) => e.id === expense.id);
|
||||
if (exists) {
|
||||
setOneOffExpenses(oneOffExpenses.map((e) => (e.id === expense.id ? expense : e)));
|
||||
} else {
|
||||
setOneOffExpenses([expense, ...oneOffExpenses]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOneOff = (expenseId: string) => {
|
||||
if (window.confirm('Delete this one-off shared charge?')) {
|
||||
setOneOffExpenses(oneOffExpenses.filter((e) => e.id !== expenseId));
|
||||
}
|
||||
};
|
||||
|
||||
// Handlers for Members & Groups
|
||||
const handleAddMember = (m: Omit<Member, 'id'>) => {
|
||||
const newMember: Member = { ...m, id: `mem-${Date.now()}` };
|
||||
setMembers([...members, newMember]);
|
||||
};
|
||||
|
||||
const handleEditMember = (m: Member) => {
|
||||
setMembers(members.map((item) => (item.id === m.id ? m : item)));
|
||||
};
|
||||
|
||||
const handleDeleteMember = (memberId: string) => {
|
||||
if (window.confirm('Remove this member?')) {
|
||||
setMembers(members.filter((m) => m.id !== memberId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGroup = (g: Omit<Group, 'id'>) => {
|
||||
const newGroup: Group = { ...g, id: `grp-${Date.now()}` };
|
||||
setGroups([...groups, newGroup]);
|
||||
};
|
||||
|
||||
const handleEditGroup = (g: Group) => {
|
||||
setGroups(groups.map((item) => (item.id === g.id ? g : item)));
|
||||
};
|
||||
|
||||
const handleDeleteGroup = (groupId: string) => {
|
||||
if (window.confirm('Delete this family group? Members in this group will become individual.')) {
|
||||
setGroups(groups.filter((g) => g.id !== groupId));
|
||||
setMembers(members.map((m) => (m.groupId === groupId ? { ...m, groupId: undefined } : m)));
|
||||
}
|
||||
};
|
||||
|
||||
// Settlement Handlers
|
||||
const handleOpenSettleModal = (fromId?: string, toId?: string, defaultAmt?: number) => {
|
||||
setSettleFromId(fromId);
|
||||
setSettleToId(toId);
|
||||
setSettleDefaultAmount(defaultAmt);
|
||||
setIsSettleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveSettlement = (record: Omit<SettlementRecord, 'id'>) => {
|
||||
const newRecord: SettlementRecord = { ...record, id: `settle-${Date.now()}` };
|
||||
setSettlements([newRecord, ...settlements]);
|
||||
};
|
||||
|
||||
const handleDeleteSettlement = (id: string) => {
|
||||
setSettlements(settlements.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
const handleResetData = () => {
|
||||
if (window.confirm('Reset all subscriptions, one-off charges, members, and settlement records to initial seed data?')) {
|
||||
setGroups(INITIAL_GROUPS);
|
||||
setMembers(INITIAL_MEMBERS);
|
||||
setSubscriptions(INITIAL_SUBSCRIPTIONS);
|
||||
setOneOffExpenses(INITIAL_ONE_OFF_EXPENSES);
|
||||
setSettlements(INITIAL_SETTLEMENTS);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100/70 text-slate-900 font-sans antialiased pb-16">
|
||||
{/* Top Header & Navigation */}
|
||||
<Header
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
totalActiveSubs={activeSubs.length}
|
||||
totalOneOffCount={oneOffExpenses.length}
|
||||
totalMonthlyCost={totalMonthlyCost}
|
||||
onOpenAddSub={() => {
|
||||
setEditingSub(null);
|
||||
setIsSubModalOpen(true);
|
||||
}}
|
||||
onOpenAddExpense={() => {
|
||||
setEditingOneOff(null);
|
||||
setIsOneOffModalOpen(true);
|
||||
}}
|
||||
onOpenRecordSettlement={() => handleOpenSettleModal()}
|
||||
onOpenShareReport={() => setIsShareReportOpen(true)}
|
||||
onResetData={handleResetData}
|
||||
/>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{activeTab === 'dashboard' && (
|
||||
<Dashboard
|
||||
balances={balances}
|
||||
debts={debts}
|
||||
subscriptions={subscriptions}
|
||||
oneOffExpenses={oneOffExpenses}
|
||||
settlements={settlements}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onOpenSettleModal={handleOpenSettleModal}
|
||||
onOpenBreakdownModal={(sub) => setBreakdownSub(sub)}
|
||||
onOpenMemberBreakdownModal={(bal) => setSelectedMemberBalance(bal)}
|
||||
onOpenAddExpense={() => {
|
||||
setEditingOneOff(null);
|
||||
setIsOneOffModalOpen(true);
|
||||
}}
|
||||
onOpenAddSub={() => {
|
||||
setEditingSub(null);
|
||||
setIsSubModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'subscriptions' && (
|
||||
<SubscriptionList
|
||||
subscriptions={subscriptions}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onAddSub={() => {
|
||||
setEditingSub(null);
|
||||
setIsSubModalOpen(true);
|
||||
}}
|
||||
onEditSub={(sub) => {
|
||||
setEditingSub(sub);
|
||||
setIsSubModalOpen(true);
|
||||
}}
|
||||
onDeleteSub={handleDeleteSub}
|
||||
onToggleStatus={handleToggleSubStatus}
|
||||
onOpenBreakdownModal={(sub) => setBreakdownSub(sub)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'one-off' && (
|
||||
<OneOffExpenseList
|
||||
expenses={oneOffExpenses}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onAddExpense={() => {
|
||||
setEditingOneOff(null);
|
||||
setIsOneOffModalOpen(true);
|
||||
}}
|
||||
onEditExpense={(exp) => {
|
||||
setEditingOneOff(exp);
|
||||
setIsOneOffModalOpen(true);
|
||||
}}
|
||||
onDeleteExpense={handleDeleteOneOff}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && (
|
||||
<GroupMemberManager
|
||||
members={members}
|
||||
groups={groups}
|
||||
onAddMember={handleAddMember}
|
||||
onEditMember={handleEditMember}
|
||||
onDeleteMember={handleDeleteMember}
|
||||
onAddGroup={handleAddGroup}
|
||||
onEditGroup={handleEditGroup}
|
||||
onDeleteGroup={handleDeleteGroup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'settlements' && (
|
||||
<SettlementLog
|
||||
settlements={settlements}
|
||||
members={members}
|
||||
onOpenSettleModal={() => handleOpenSettleModal()}
|
||||
onDeleteSettlement={handleDeleteSettlement}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Subscription Modal (Create / Edit) */}
|
||||
<SubscriptionModal
|
||||
isOpen={isSubModalOpen}
|
||||
onClose={() => setIsSubModalOpen(false)}
|
||||
onSave={handleSaveSub}
|
||||
initialData={editingSub}
|
||||
members={members}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
{/* One-Off Expense Modal (Create / Edit) */}
|
||||
<OneOffExpenseModal
|
||||
isOpen={isOneOffModalOpen}
|
||||
onClose={() => setIsOneOffModalOpen(false)}
|
||||
onSave={handleSaveOneOff}
|
||||
initialData={editingOneOff}
|
||||
members={members}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
{/* Settlement Payment Modal */}
|
||||
<SettlementModal
|
||||
isOpen={isSettleModalOpen}
|
||||
onClose={() => setIsSettleModalOpen(false)}
|
||||
onSaveSettlement={handleSaveSettlement}
|
||||
members={members}
|
||||
defaultFromId={settleFromId}
|
||||
defaultToId={settleToId}
|
||||
defaultAmount={settleDefaultAmount}
|
||||
/>
|
||||
|
||||
{/* Subscription Math Breakdown Modal */}
|
||||
<BreakdownModal
|
||||
subscription={breakdownSub}
|
||||
onClose={() => setBreakdownSub(null)}
|
||||
members={members}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
{/* Member Balance Statement Modal */}
|
||||
<MemberBreakdownModal
|
||||
balance={selectedMemberBalance}
|
||||
onClose={() => setSelectedMemberBalance(null)}
|
||||
members={members}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
{/* Share/Export Chat Report Modal */}
|
||||
<ShareReportModal
|
||||
isOpen={isShareReportOpen}
|
||||
onClose={() => setIsShareReportOpen(false)}
|
||||
balances={balances}
|
||||
debts={debts}
|
||||
subscriptions={subscriptions}
|
||||
oneOffExpenses={oneOffExpenses}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Subscription, Member, Group } from '../types';
|
||||
import { calculateSubscriptionMemberShares, getMonthlyCost } from '../utils/splitCalculator';
|
||||
import { X, Calculator, CreditCard, Calendar, Users, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface BreakdownModalProps {
|
||||
subscription: Subscription | null;
|
||||
onClose: () => void;
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
export const BreakdownModal: React.FC<BreakdownModalProps> = ({
|
||||
subscription,
|
||||
onClose,
|
||||
members,
|
||||
groups,
|
||||
}) => {
|
||||
if (!subscription) return null;
|
||||
|
||||
const monthlyCost = getMonthlyCost(subscription.cost, subscription.billingCycle);
|
||||
const { shares, explanations } = calculateSubscriptionMemberShares(subscription, members, groups);
|
||||
const payer = members.find((m) => m.id === subscription.payerId);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full border border-slate-200 shadow-2xl p-6 space-y-5 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calculator className="w-5 h-5 text-indigo-600" />
|
||||
<h3 className="font-bold text-slate-900 text-base">Subscription Dues Math</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:bg-slate-100">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subscription Card Overview */}
|
||||
<div className="bg-slate-900 text-white p-4 rounded-xl space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-indigo-300 bg-indigo-500/20 px-2 py-0.5 rounded">
|
||||
{subscription.category}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300">
|
||||
Payer: <strong className="text-white">{payer?.name || 'Unknown'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-lg font-bold">{subscription.name}</h4>
|
||||
<div className="flex items-baseline justify-between pt-1 border-t border-slate-800 text-xs">
|
||||
<span>
|
||||
Total Cost: <strong>${subscription.cost.toFixed(2)}</strong> ({subscription.billingCycle})
|
||||
</span>
|
||||
<span className="font-bold text-emerald-400">${monthlyCost.toFixed(2)}/mo normalized</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Math Explanation Header */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<h5 className="font-bold text-slate-800 uppercase text-[10px] tracking-wider">
|
||||
Split Engine Logic ({subscription.splitModel})
|
||||
</h5>
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{members.map((m) => {
|
||||
const share = shares[m.id] || 0;
|
||||
const exp = explanations[m.id] || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`p-3 rounded-xl border flex items-center justify-between ${
|
||||
share > 0 ? 'bg-slate-50 border-slate-200' : 'bg-slate-50/40 border-slate-100 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className={`w-7 h-7 rounded-full ${m.avatarColor} flex items-center justify-center font-bold text-xs`}>
|
||||
{m.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-900 block">{m.name}</span>
|
||||
<span className="text-[10px] text-slate-500">{exp}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="font-black text-slate-900 text-sm block">${share.toFixed(2)}</span>
|
||||
<span className="text-[9px] text-slate-400">/month</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-3 border-t border-slate-100">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,481 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
MemberBalance,
|
||||
DebtTransaction,
|
||||
Subscription,
|
||||
OneOffExpense,
|
||||
SettlementRecord,
|
||||
Group,
|
||||
Member,
|
||||
} from '../types';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Receipt,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
CreditCard,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { NextPayerAdvisor } from './NextPayerAdvisor';
|
||||
|
||||
interface DashboardProps {
|
||||
balances: MemberBalance[];
|
||||
debts: DebtTransaction[];
|
||||
subscriptions: Subscription[];
|
||||
oneOffExpenses?: OneOffExpense[];
|
||||
settlements?: SettlementRecord[];
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
onOpenSettleModal: (fromMemberId?: string, toMemberId?: string, defaultAmount?: number) => void;
|
||||
onOpenBreakdownModal: (sub: Subscription) => void;
|
||||
onOpenMemberBreakdownModal?: (bal: MemberBalance) => void;
|
||||
onOpenAddExpense?: () => void;
|
||||
onOpenAddSub?: () => void;
|
||||
}
|
||||
|
||||
export const Dashboard: React.FC<DashboardProps> = ({
|
||||
balances,
|
||||
debts,
|
||||
subscriptions,
|
||||
oneOffExpenses = [],
|
||||
settlements = [],
|
||||
members,
|
||||
groups,
|
||||
onOpenSettleModal,
|
||||
onOpenBreakdownModal,
|
||||
onOpenMemberBreakdownModal,
|
||||
onOpenAddExpense,
|
||||
onOpenAddSub,
|
||||
}) => {
|
||||
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
|
||||
|
||||
// Calculate overall statistics
|
||||
const activeSubs = subscriptions.filter((s) => s.status === 'active');
|
||||
const totalMonthlyOutlay = activeSubs.reduce((acc, s) => {
|
||||
return acc + (s.billingCycle === 'yearly' ? s.cost / 12 : s.cost);
|
||||
}, 0);
|
||||
|
||||
const totalOneOffOutlay = oneOffExpenses.reduce((acc, e) => acc + e.amount, 0);
|
||||
|
||||
// Group helpers
|
||||
const getGroupName = (groupId?: string) => {
|
||||
if (!groupId) return 'Individual';
|
||||
return groups.find((g) => g.id === groupId)?.name || 'Group';
|
||||
};
|
||||
|
||||
// Calculate upcoming renewals sorted by date
|
||||
const now = new Date();
|
||||
const upcomingRenewals = [...activeSubs]
|
||||
.map((s) => {
|
||||
const renewal = new Date(s.renewalDate);
|
||||
const diffTime = renewal.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return { sub: s, daysLeft: diffDays };
|
||||
})
|
||||
.filter((item) => item.daysLeft >= 0 && item.daysLeft <= 30)
|
||||
.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
|
||||
// Category breakdown
|
||||
const categoryTotals: Record<string, number> = {};
|
||||
activeSubs.forEach((s) => {
|
||||
const cost = s.billingCycle === 'yearly' ? s.cost / 12 : s.cost;
|
||||
categoryTotals[s.category] = (categoryTotals[s.category] || 0) + cost;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Overview Metric Banner */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200 shadow-xs flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-400 block tracking-wider">
|
||||
Monthly Subscriptions
|
||||
</span>
|
||||
<span className="text-xl font-black text-slate-900">
|
||||
${totalMonthlyOutlay.toFixed(2)}<span className="text-xs text-slate-400 font-normal">/mo</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500 block">{activeSubs.length} active recurring plans</span>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200 shadow-xs flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-400 block tracking-wider">
|
||||
One-Off Charges Outlay
|
||||
</span>
|
||||
<span className="text-xl font-black text-slate-900">${totalOneOffOutlay.toFixed(2)}</span>
|
||||
<span className="text-[11px] text-slate-500 block">{oneOffExpenses.length} shared events logged</span>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-50 text-amber-600 rounded-xl">
|
||||
<Receipt className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200 shadow-xs flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase font-semibold text-slate-400 block tracking-wider">
|
||||
Net Outstanding Debts
|
||||
</span>
|
||||
<span className={`text-xl font-black ${debts.length > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||
{debts.length === 0 ? 'All Settled!' : `${debts.length} Pending`}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500 block">
|
||||
{debts.length === 0 ? 'Zero balance owing' : 'Minimizes settlement transfers'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Next Payer Advisor */}
|
||||
<NextPayerAdvisor
|
||||
balances={balances}
|
||||
members={members}
|
||||
onOpenAddExpense={onOpenAddExpense}
|
||||
onOpenAddSub={onOpenAddSub}
|
||||
/>
|
||||
|
||||
{/* 1. Who Owes Whom (Settlement Recommendations) */}
|
||||
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-xs">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-4 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<Receipt className="w-5 h-5 text-indigo-600" />
|
||||
Settlement Matrix ("Who Owes Whom")
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Minimizes transactions required to settle all shared subscription dues and one-off event charges.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{onOpenAddExpense && (
|
||||
<button
|
||||
onClick={onOpenAddExpense}
|
||||
className="px-3.5 py-2 bg-amber-50 hover:bg-amber-100 text-amber-900 border border-amber-300 rounded-xl text-xs font-semibold transition flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log One-Off Charge</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onOpenSettleModal()}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-semibold shadow-xs transition flex items-center gap-1.5"
|
||||
>
|
||||
<span>Record Settlement</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{debts.length === 0 ? (
|
||||
<div className="p-6 bg-emerald-50/70 border border-emerald-200/80 rounded-xl text-center space-y-2">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-600 mx-auto" />
|
||||
<h3 className="font-bold text-emerald-950 text-sm">All Accounts Settled Up!</h3>
|
||||
<p className="text-xs text-emerald-800 max-w-md mx-auto font-medium">
|
||||
Everyone has paid their exact calculated share for all active subscriptions and one-off event charges. No outstanding debts!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{debts.map((debt, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 bg-slate-50/80 hover:bg-slate-100/90 border border-slate-200/80 rounded-xl transition flex flex-col justify-between space-y-3 shadow-xs hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs font-bold text-slate-800">{debt.fromMemberName}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-slate-400">
|
||||
<span className="text-[10px] font-semibold text-slate-500 uppercase tracking-wider">owes</span>
|
||||
<ArrowUpRight className="w-4 h-4 text-rose-500" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs font-bold text-slate-800">{debt.toMemberName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2.5 border-t border-slate-200/80 gap-2">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-400 block font-semibold">Amount Due</span>
|
||||
<span className="text-lg font-black text-slate-900">${debt.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenSettleModal(debt.fromMemberId, debt.toMemberId, debt.amount)}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 active:scale-[0.98] text-white text-xs font-bold rounded-lg transition shadow-xs shrink-0"
|
||||
>
|
||||
Settle Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 2. Member Net Balance Matrix */}
|
||||
<section className="bg-white rounded-2xl p-6 border border-slate-200 shadow-xs space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900">Member Net Balances</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Combines recurring subscription shares, one-off charges, and past settlements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-md font-medium">
|
||||
<ArrowDownLeft className="w-3.5 h-3.5 text-emerald-600" /> Owed Credit (+)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-rose-50 text-rose-700 border border-rose-200 rounded-md font-medium">
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-rose-600" /> Owes Debt (-)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{balances.map((bal) => {
|
||||
const isOwed = bal.netMonthlyBalance > 0.01;
|
||||
const owesMoney = bal.netMonthlyBalance < -0.01;
|
||||
const isExpanded = expandedMemberId === bal.memberId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={bal.memberId}
|
||||
className={`p-4 rounded-xl border transition-all ${
|
||||
isOwed
|
||||
? 'bg-emerald-50/30 border-emerald-200 hover:border-emerald-300'
|
||||
: owesMoney
|
||||
? 'bg-rose-50/30 border-rose-200 hover:border-rose-300'
|
||||
: 'bg-slate-50/50 border-slate-200'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full ${bal.avatarColor} flex items-center justify-center font-bold text-xs shadow-xs text-white`}
|
||||
>
|
||||
{bal.memberName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-900">{bal.memberName}</h3>
|
||||
<span className="text-[11px] text-slate-500">{getGroupName(bal.groupId)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net Status Pill */}
|
||||
<div className="text-right">
|
||||
{isOwed ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||
+${bal.netMonthlyBalance.toFixed(2)}
|
||||
</span>
|
||||
) : owesMoney ? (
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-rose-100 text-rose-800 border border-rose-300">
|
||||
-${Math.abs(bal.netMonthlyBalance).toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-200 text-slate-700">
|
||||
$0.00
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle explanation */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs bg-white/80 p-2.5 rounded-lg border border-slate-200/70 mb-3">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase text-slate-400 font-semibold block">Total Upfront</span>
|
||||
<span className="font-bold text-slate-800">
|
||||
${(bal.totalPaidUpfrontMonthly + bal.totalPaidOneOff).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase text-slate-400 font-semibold block">Fair Share</span>
|
||||
<span className="font-bold text-slate-800">
|
||||
${(bal.totalOwedMonthly + bal.totalOwedOneOff).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between gap-2 pt-1 border-t border-slate-200/60 text-xs">
|
||||
{onOpenMemberBreakdownModal && (
|
||||
<button
|
||||
onClick={() => onOpenMemberBreakdownModal(bal)}
|
||||
className="px-2.5 py-1 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 rounded-lg font-semibold text-[11px] flex items-center gap-1 transition"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>View Statement</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedMemberId(isExpanded ? null : bal.memberId)}
|
||||
className="text-[11px] font-semibold text-slate-600 hover:text-indigo-600 flex items-center gap-1 py-1 transition ml-auto"
|
||||
>
|
||||
<span>{isExpanded ? 'Hide' : 'Quick Preview'}</span>
|
||||
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded Quick Details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200 space-y-3 text-xs animate-in fade-in duration-200">
|
||||
<div>
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">
|
||||
Subscriptions ({bal.dueBreakdown.length}):
|
||||
</span>
|
||||
{bal.dueBreakdown.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-400 italic">No assigned subscription dues.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{bal.dueBreakdown.map((due, dIdx) => (
|
||||
<div
|
||||
key={dIdx}
|
||||
className="flex items-center justify-between p-1.5 bg-slate-100/70 rounded-md border border-slate-200/60 text-[11px]"
|
||||
>
|
||||
<span className="font-medium text-slate-800 truncate">{due.subscriptionName}</span>
|
||||
<span className="font-bold text-slate-900 shrink-0 ml-2">
|
||||
${due.myMonthlyShare.toFixed(2)}/mo
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[11px] font-semibold text-slate-600 block mb-1">
|
||||
One-Off Charges ({bal.expenseBreakdown.length}):
|
||||
</span>
|
||||
{bal.expenseBreakdown.length === 0 ? (
|
||||
<p className="text-[11px] text-slate-400 italic">No assigned one-off charges.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{bal.expenseBreakdown.map((exp, eIdx) => (
|
||||
<div
|
||||
key={eIdx}
|
||||
className="flex items-center justify-between p-1.5 bg-amber-50/70 rounded-md border border-amber-200/60 text-[11px]"
|
||||
>
|
||||
<span className="font-medium text-slate-800 truncate">{exp.expenseTitle}</span>
|
||||
<span className="font-bold text-slate-900 shrink-0 ml-2">
|
||||
${exp.myShare.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3. Secondary Analytics Grid (Upcoming Renewals & Category Breakdown) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Renewals Timeline */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="font-bold text-slate-900 text-base flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-indigo-600" />
|
||||
Upcoming Renewals (30 Days)
|
||||
</h3>
|
||||
<span className="text-xs text-slate-500">{upcomingRenewals.length} upcoming</span>
|
||||
</div>
|
||||
|
||||
{upcomingRenewals.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-4 text-center">No subscriptions renewing in the next 30 days.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingRenewals.map(({ sub, daysLeft }) => {
|
||||
const payer = members.find((m) => m.id === sub.payerId);
|
||||
const isUrgent = daysLeft <= 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="p-3 bg-slate-50 hover:bg-slate-100/70 rounded-xl border border-slate-200 flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`px-2.5 py-1 rounded-lg font-bold text-center ${
|
||||
isUrgent ? 'bg-amber-100 text-amber-800 border border-amber-300' : 'bg-slate-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[10px] block font-medium uppercase text-slate-500">In</span>
|
||||
<span className="text-xs">{daysLeft === 0 ? 'Today' : `${daysLeft} days`}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900">{sub.name}</h4>
|
||||
<span className="text-[11px] text-slate-500">
|
||||
Payer: <strong className="text-slate-700">{payer?.name || 'Unknown'}</strong> ({sub.billingCycle})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-slate-900 block text-sm">${sub.cost.toFixed(2)}</span>
|
||||
<button
|
||||
onClick={() => onOpenBreakdownModal(sub)}
|
||||
className="text-[11px] text-indigo-600 hover:underline font-medium"
|
||||
>
|
||||
Math Breakdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<h3 className="font-bold text-slate-900 text-base flex items-center gap-2">
|
||||
<HelpCircle className="w-4 h-4 text-purple-600" />
|
||||
Monthly Outlay by Category
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-slate-800">${totalMonthlyOutlay.toFixed(2)} total</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{Object.entries(categoryTotals).map(([cat, amount]) => {
|
||||
const pct = totalMonthlyOutlay > 0 ? (amount / totalMonthlyOutlay) * 100 : 0;
|
||||
return (
|
||||
<div key={cat} className="space-y-1 text-xs">
|
||||
<div className="flex justify-between font-medium">
|
||||
<span className="text-slate-700">{cat}</span>
|
||||
<span className="font-bold text-slate-900">
|
||||
${amount.toFixed(2)}/mo <span className="text-slate-400 font-normal">({pct.toFixed(0)}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-indigo-600 h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Member, Group } from '../types';
|
||||
import { Users, UserPlus, Plus, FolderPlus, Edit2, Trash2, Mail } from 'lucide-react';
|
||||
|
||||
interface GroupMemberManagerProps {
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
onAddMember: (member: Omit<Member, 'id'>) => void;
|
||||
onEditMember: (member: Member) => void;
|
||||
onDeleteMember: (memberId: string) => void;
|
||||
onAddGroup: (group: Omit<Group, 'id'>) => void;
|
||||
onEditGroup: (group: Group) => void;
|
||||
onDeleteGroup: (groupId: string) => void;
|
||||
}
|
||||
|
||||
export const GroupMemberManager: React.FC<GroupMemberManagerProps> = ({
|
||||
members,
|
||||
groups,
|
||||
onAddMember,
|
||||
onEditMember,
|
||||
onDeleteMember,
|
||||
onAddGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
}) => {
|
||||
// Member Form Modal state
|
||||
const [isMemberModalOpen, setIsMemberModalOpen] = useState(false);
|
||||
const [editingMember, setEditingMember] = useState<Member | null>(null);
|
||||
const [memberName, setMemberName] = useState('');
|
||||
const [memberEmail, setMemberEmail] = useState('');
|
||||
const [memberGroupId, setMemberGroupId] = useState<string>('');
|
||||
const [memberColor, setMemberColor] = useState('bg-indigo-500 text-white');
|
||||
|
||||
// Group Form Modal state
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groupDesc, setGroupDesc] = useState('');
|
||||
const [groupColor, setGroupColor] = useState('bg-indigo-500 text-indigo-950 border-indigo-300');
|
||||
|
||||
const avatarColors = [
|
||||
'bg-rose-500 text-white',
|
||||
'bg-sky-500 text-white',
|
||||
'bg-emerald-500 text-white',
|
||||
'bg-purple-500 text-white',
|
||||
'bg-amber-500 text-white',
|
||||
'bg-teal-500 text-white',
|
||||
'bg-indigo-500 text-white',
|
||||
'bg-pink-500 text-white',
|
||||
];
|
||||
|
||||
const handleOpenAddMember = () => {
|
||||
setEditingMember(null);
|
||||
setMemberName('');
|
||||
setMemberEmail('');
|
||||
setMemberGroupId('');
|
||||
setMemberColor(avatarColors[0]);
|
||||
setIsMemberModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditMember = (m: Member) => {
|
||||
setEditingMember(m);
|
||||
setMemberName(m.name);
|
||||
setMemberEmail(m.email || '');
|
||||
setMemberGroupId(m.groupId || '');
|
||||
setMemberColor(m.avatarColor);
|
||||
setIsMemberModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveMember = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!memberName.trim()) return;
|
||||
|
||||
if (editingMember) {
|
||||
onEditMember({
|
||||
...editingMember,
|
||||
name: memberName,
|
||||
email: memberEmail || undefined,
|
||||
groupId: memberGroupId || undefined,
|
||||
avatarColor: memberColor,
|
||||
});
|
||||
} else {
|
||||
onAddMember({
|
||||
name: memberName,
|
||||
email: memberEmail || undefined,
|
||||
groupId: memberGroupId || undefined,
|
||||
avatarColor: memberColor,
|
||||
});
|
||||
}
|
||||
setIsMemberModalOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenAddGroup = () => {
|
||||
setGroupName('');
|
||||
setGroupDesc('');
|
||||
setIsGroupModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveGroup = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!groupName.trim()) return;
|
||||
|
||||
onAddGroup({
|
||||
name: groupName,
|
||||
description: groupDesc || undefined,
|
||||
color: groupColor,
|
||||
});
|
||||
setIsGroupModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 1. Family Groups Section */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-indigo-600" />
|
||||
Family Groups & Households
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">
|
||||
Group members together (e.g. "The Smiths", "Apartment 4B") for shared family percentage splits.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenAddGroup}
|
||||
className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1.5"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
<span>Create Family Group</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{groups.map((grp) => {
|
||||
const groupMembers = members.filter((m) => m.groupId === grp.id);
|
||||
return (
|
||||
<div key={grp.id} className="p-4 rounded-xl border border-slate-200 bg-slate-50/60 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 block">Family Unit</span>
|
||||
<h3 className="font-bold text-slate-900 text-base">{grp.name}</h3>
|
||||
{grp.description && <p className="text-xs text-slate-500">{grp.description}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDeleteGroup(grp.id)}
|
||||
className="p-1 text-slate-400 hover:text-rose-600 transition"
|
||||
title="Delete Group"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<span className="text-[10px] font-semibold text-slate-400 uppercase block mb-1">
|
||||
Members ({groupMembers.length}):
|
||||
</span>
|
||||
{groupMembers.length === 0 ? (
|
||||
<span className="text-xs text-slate-400 italic">No members assigned yet</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{groupMembers.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="px-2 py-1 bg-white border border-slate-200 rounded-lg text-xs font-medium text-slate-700 flex items-center gap-1.5"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${m.avatarColor}`} />
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Members Section */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-indigo-600" />
|
||||
Members Directory
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">Individual participants who share subscriptions and settle dues.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenAddMember}
|
||||
className="px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1.5"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>Add Member</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{members.map((m) => {
|
||||
const group = groups.find((g) => g.id === m.groupId);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="p-4 rounded-xl border border-slate-200 bg-slate-50/50 hover:bg-slate-50 transition flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-full ${m.avatarColor} flex items-center justify-center font-bold text-sm shadow-sm`}>
|
||||
{m.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 text-sm">{m.name}</h3>
|
||||
<div className="flex items-center space-x-2 text-[11px] text-slate-500">
|
||||
<span>{group ? group.name : 'Individual'}</span>
|
||||
{m.email && (
|
||||
<span className="flex items-center gap-0.5 text-slate-400">
|
||||
<Mail className="w-3 h-3" />
|
||||
{m.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => handleOpenEditMember(m)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-200/60 rounded-lg transition"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteMember(m.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member Form Modal */}
|
||||
{isMemberModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full border border-slate-200 shadow-2xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-slate-900 text-base">{editingMember ? 'Edit Member' : 'Add New Member'}</h3>
|
||||
<form onSubmit={handleSaveMember} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Jane Smith"
|
||||
value={memberName}
|
||||
onChange={(e) => setMemberName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Email Address (Optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="e.g. jane@example.com"
|
||||
value={memberEmail}
|
||||
onChange={(e) => setMemberEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Family Group / Household</label>
|
||||
<select
|
||||
value={memberGroupId}
|
||||
onChange={(e) => setMemberGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="">No Group (Individual)</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Avatar Color Theme</label>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{avatarColors.map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
type="button"
|
||||
onClick={() => setMemberColor(col)}
|
||||
className={`w-7 h-7 rounded-full ${col} flex items-center justify-center font-bold text-xs border-2 transition ${
|
||||
memberColor === col ? 'border-slate-900 scale-110' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMemberModalOpen(false)}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-700 hover:bg-slate-100 rounded-xl font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold">
|
||||
Save Member
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Form Modal */}
|
||||
{isGroupModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full border border-slate-200 shadow-2xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-slate-900 text-base">Create Family Group</h3>
|
||||
<form onSubmit={handleSaveGroup} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Group Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. The Smiths, Beach House Group"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Description (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Main household unit"
|
||||
value={groupDesc}
|
||||
onChange={(e) => setGroupDesc(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsGroupModalOpen(false)}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-700 hover:bg-slate-100 rounded-xl font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl font-bold">
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, Users, RefreshCw, Share2, Plus, Sparkles, Receipt, DollarSign } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: 'dashboard' | 'subscriptions' | 'one-off' | 'members' | 'settlements';
|
||||
setActiveTab: (tab: 'dashboard' | 'subscriptions' | 'one-off' | 'members' | 'settlements') => void;
|
||||
totalActiveSubs: number;
|
||||
totalOneOffCount?: number;
|
||||
totalMonthlyCost: number;
|
||||
onOpenAddSub: () => void;
|
||||
onOpenAddExpense?: () => void;
|
||||
onOpenRecordSettlement?: () => void;
|
||||
onOpenShareReport: () => void;
|
||||
onResetData: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
totalActiveSubs,
|
||||
totalOneOffCount = 0,
|
||||
totalMonthlyCost,
|
||||
onOpenAddSub,
|
||||
onOpenAddExpense,
|
||||
onOpenRecordSettlement,
|
||||
onOpenShareReport,
|
||||
onResetData,
|
||||
}) => {
|
||||
return (
|
||||
<header className="bg-slate-900 text-white border-b border-slate-800/80 sticky top-0 z-30 shadow-md backdrop-blur-md bg-slate-900/95">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between py-3.5 gap-4">
|
||||
{/* Logo & Brand */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 via-indigo-600 to-violet-700 flex items-center justify-center shadow-md shadow-indigo-500/25 ring-1 ring-white/20">
|
||||
<CreditCard className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-extrabold tracking-tight text-white">SubSplit</h1>
|
||||
<span className="px-2.5 py-0.5 text-[11px] font-semibold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 rounded-full flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-indigo-400" /> Multi-Family
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 font-medium">Shared Subscription & One-Off Charges Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Mobile */}
|
||||
<div className="flex md:hidden items-center space-x-1.5">
|
||||
{onOpenRecordSettlement && (
|
||||
<button
|
||||
onClick={onOpenRecordSettlement}
|
||||
className="p-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl transition text-xs font-semibold flex items-center shadow-xs"
|
||||
title="Record Settlement"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onOpenAddExpense && (
|
||||
<button
|
||||
onClick={onOpenAddExpense}
|
||||
className="p-2 bg-amber-600 hover:bg-amber-500 text-white rounded-xl transition text-xs font-semibold flex items-center shadow-xs"
|
||||
title="Add One-Off Charge"
|
||||
>
|
||||
<Receipt className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenAddSub}
|
||||
className="p-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl transition text-xs font-semibold flex items-center shadow-xs"
|
||||
title="Add Subscription"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Metrics Bar */}
|
||||
<div className="flex items-center justify-between md:justify-end space-x-3.5 bg-slate-800/90 p-2 sm:p-2.5 rounded-xl border border-slate-700/70 text-xs sm:text-sm shadow-inner">
|
||||
<div className="px-3 border-r border-slate-700/80">
|
||||
<span className="text-slate-400 block text-[10px] uppercase tracking-wider font-semibold">Active Subs</span>
|
||||
<span className="font-bold text-white text-base leading-tight">{totalActiveSubs}</span>
|
||||
</div>
|
||||
<div className="px-3 border-r border-slate-700/80">
|
||||
<span className="text-slate-400 block text-[10px] uppercase tracking-wider font-semibold">Total Outlay</span>
|
||||
<span className="font-bold text-emerald-400 text-base leading-tight">${totalMonthlyCost.toFixed(2)}<span className="text-xs text-slate-400 font-normal">/mo</span></span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pl-1">
|
||||
<button
|
||||
onClick={onOpenShareReport}
|
||||
className="px-3 py-1.5 bg-slate-700/80 hover:bg-slate-700 text-slate-200 hover:text-white rounded-lg text-xs font-semibold transition flex items-center gap-1.5 border border-slate-600/50"
|
||||
title="Share Chat Balance Report"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<span className="hidden sm:inline">Export Report</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onResetData}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/70 rounded-lg transition"
|
||||
title="Reset to Seed Data"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
{onOpenRecordSettlement && (
|
||||
<button
|
||||
onClick={onOpenRecordSettlement}
|
||||
className="hidden md:flex px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 active:scale-[0.98] text-white rounded-lg text-xs font-semibold transition items-center gap-1.5 shadow-sm shadow-emerald-600/20"
|
||||
>
|
||||
<DollarSign className="w-3.5 h-3.5" />
|
||||
<span>Record Settlement</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenAddExpense && (
|
||||
<button
|
||||
onClick={onOpenAddExpense}
|
||||
className="hidden md:flex px-3 py-1.5 bg-amber-600 hover:bg-amber-500 active:scale-[0.98] text-white rounded-lg text-xs font-semibold transition items-center gap-1.5 shadow-sm shadow-amber-600/20"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log One-Off</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenAddSub}
|
||||
className="hidden md:flex px-3.5 py-1.5 bg-indigo-600 hover:bg-indigo-500 active:scale-[0.98] text-white rounded-lg text-xs font-semibold transition items-center gap-1.5 shadow-sm shadow-indigo-600/30"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Sub</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex space-x-1 border-t border-slate-800/80 pt-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-t-xl transition flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === 'dashboard'
|
||||
? 'bg-slate-800 text-indigo-400 border-t-2 border-indigo-500 shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
<span>Dashboard & Balances</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('subscriptions')}
|
||||
className={`px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-t-xl transition flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === 'subscriptions'
|
||||
? 'bg-slate-800 text-indigo-400 border-t-2 border-indigo-500 shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
<span>Subscriptions ({totalActiveSubs})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('one-off')}
|
||||
className={`px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-t-xl transition flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === 'one-off'
|
||||
? 'bg-slate-800 text-amber-400 border-t-2 border-amber-500 shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Receipt className="w-4 h-4 text-amber-400" />
|
||||
<span>One-Off Charges ({totalOneOffCount})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('members')}
|
||||
className={`px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-t-xl transition flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === 'members'
|
||||
? 'bg-slate-800 text-indigo-400 border-t-2 border-indigo-500 shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Members & Families</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settlements')}
|
||||
className={`px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-t-xl transition flex items-center gap-2 whitespace-nowrap ${
|
||||
activeTab === 'settlements'
|
||||
? 'bg-slate-800 text-indigo-400 border-t-2 border-indigo-500 shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Settlement Log</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import { MemberBalance, Member, Group } from '../types';
|
||||
import { X, User, CreditCard, Receipt, DollarSign, ArrowUpRight, ArrowDownLeft, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface MemberBreakdownModalProps {
|
||||
balance: MemberBalance | null;
|
||||
onClose: () => void;
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
export const MemberBreakdownModal: React.FC<MemberBreakdownModalProps> = ({
|
||||
balance,
|
||||
onClose,
|
||||
members,
|
||||
groups,
|
||||
}) => {
|
||||
if (!balance) return null;
|
||||
|
||||
const memberGroup = groups.find((g) => g.id === balance.groupId);
|
||||
const isOwed = balance.netMonthlyBalance > 0.01;
|
||||
const owesMoney = balance.netMonthlyBalance < -0.01;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl max-w-xl w-full border border-slate-200 shadow-xl overflow-hidden flex flex-col max-h-[90vh] my-8">
|
||||
{/* Header Banner */}
|
||||
<div className="p-5 bg-slate-900 text-white flex items-center justify-between border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-xl ${balance.avatarColor} text-white font-bold flex items-center justify-center text-lg shadow-md`}>
|
||||
{balance.memberName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-extrabold text-lg text-white">{balance.memberName}</h3>
|
||||
{memberGroup && (
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full border ${memberGroup.color}`}>
|
||||
{memberGroup.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Complete Financial Statement & Dues Breakdown</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||
{/* Net Balance Status Card */}
|
||||
<div
|
||||
className={`p-4 rounded-2xl border flex items-center justify-between ${
|
||||
isOwed
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-950'
|
||||
: owesMoney
|
||||
? 'bg-rose-50 border-rose-200 text-rose-950'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider block text-slate-500">
|
||||
Current Net Balance
|
||||
</span>
|
||||
<div className="text-2xl font-black mt-0.5">
|
||||
{isOwed && <span className="text-emerald-700">+${balance.netMonthlyBalance.toFixed(2)}</span>}
|
||||
{owesMoney && <span className="text-rose-700">-${Math.abs(balance.netMonthlyBalance).toFixed(2)}</span>}
|
||||
{!isOwed && !owesMoney && <span className="text-slate-700">$0.00 (Settled Up)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-xs">
|
||||
{isOwed && (
|
||||
<span className="px-2.5 py-1 bg-emerald-600 text-white rounded-lg font-bold flex items-center gap-1 shadow-xs">
|
||||
<ArrowDownLeft className="w-3.5 h-3.5" /> Is Owed Money
|
||||
</span>
|
||||
)}
|
||||
{owesMoney && (
|
||||
<span className="px-2.5 py-1 bg-rose-600 text-white rounded-lg font-bold flex items-center gap-1 shadow-xs">
|
||||
<ArrowUpRight className="w-3.5 h-3.5" /> Owes Money
|
||||
</span>
|
||||
)}
|
||||
{!isOwed && !owesMoney && (
|
||||
<span className="px-2.5 py-1 bg-slate-200 text-slate-700 rounded-lg font-bold flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600" /> Settled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtotals Comparison */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">
|
||||
Upfront Outlay
|
||||
</span>
|
||||
<div className="text-sm font-bold text-slate-800 mt-1">
|
||||
Subs: ${balance.totalPaidUpfrontMonthly.toFixed(2)}/mo
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-800">
|
||||
One-Offs: ${balance.totalPaidOneOff.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">
|
||||
Fair Calculated Share
|
||||
</span>
|
||||
<div className="text-sm font-bold text-slate-800 mt-1">
|
||||
Subs: ${balance.totalOwedMonthly.toFixed(2)}/mo
|
||||
</div>
|
||||
<div className="text-sm font-bold text-slate-800">
|
||||
One-Offs: ${balance.totalOwedOneOff.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Subscriptions Dues Section */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<CreditCard className="w-4 h-4 text-indigo-600" />
|
||||
<span>Subscription Share Dues ({balance.dueBreakdown.length})</span>
|
||||
</h4>
|
||||
{balance.dueBreakdown.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic bg-slate-50 p-3 rounded-xl border border-slate-200">
|
||||
No active recurring subscriptions assigned to this member.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{balance.dueBreakdown.map((item, idx) => {
|
||||
const payer = members.find((m) => m.id === item.payerId);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 bg-slate-50 rounded-xl border border-slate-200/90 flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900">{item.subscriptionName}</div>
|
||||
<div className="text-[10px] text-slate-500">
|
||||
Payer: {payer?.name || 'Unknown'} • {item.explanation}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-black text-slate-900 text-sm">
|
||||
${item.myMonthlyShare.toFixed(2)}<span className="text-[10px] text-slate-400 font-normal">/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One-Off Expenses Section */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-bold text-slate-700 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Receipt className="w-4 h-4 text-amber-600" />
|
||||
<span>One-Off Shared Charges ({balance.expenseBreakdown.length})</span>
|
||||
</h4>
|
||||
{balance.expenseBreakdown.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic bg-slate-50 p-3 rounded-xl border border-slate-200">
|
||||
No one-off shared charges assigned to this member.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{balance.expenseBreakdown.map((item, idx) => {
|
||||
const payer = members.find((m) => m.id === item.payerId);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 bg-amber-50/50 rounded-xl border border-amber-200/80 flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900">{item.expenseTitle}</div>
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{item.date} • Payer: {payer?.name || 'Unknown'} • {item.explanation}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-black text-slate-900 text-sm">
|
||||
${item.myShare.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-200 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl text-xs transition"
|
||||
>
|
||||
Close Statement
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MemberBalance, Member } from '../types';
|
||||
import { Sparkles, ArrowRight, Plus, CreditCard, Receipt, TrendingUp, Info, CheckCircle2, ShieldAlert } from 'lucide-react';
|
||||
|
||||
interface NextPayerAdvisorProps {
|
||||
balances: MemberBalance[];
|
||||
members: Member[];
|
||||
onOpenAddExpense?: () => void;
|
||||
onOpenAddSub?: () => void;
|
||||
}
|
||||
|
||||
export const NextPayerAdvisor: React.FC<NextPayerAdvisorProps> = ({
|
||||
balances,
|
||||
members,
|
||||
onOpenAddExpense,
|
||||
onOpenAddSub,
|
||||
}) => {
|
||||
const [simulatedCost, setSimulatedCost] = useState<number>(30);
|
||||
|
||||
if (balances.length === 0) return null;
|
||||
|
||||
// Sort member balances by net balance ascending (lowest/most negative first = biggest debtor)
|
||||
const sortedBalances = [...balances].sort((a, b) => a.netMonthlyBalance - b.netMonthlyBalance);
|
||||
const topPayer = sortedBalances[0];
|
||||
|
||||
const owesMoney = topPayer.netMonthlyBalance < -0.01;
|
||||
const currentDebtAmount = Math.abs(topPayer.netMonthlyBalance);
|
||||
|
||||
// Simulation math
|
||||
const memberCount = Math.max(members.length, 1);
|
||||
const topPayerShare = simulatedCost / memberCount;
|
||||
const netCreditGain = simulatedCost - topPayerShare;
|
||||
const simulatedNewNet = topPayer.netMonthlyBalance + netCreditGain;
|
||||
|
||||
const debtReduction = owesMoney
|
||||
? Math.min(currentDebtAmount, netCreditGain)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gradient-to-br from-indigo-900 via-slate-900 to-slate-950 text-white rounded-2xl border border-indigo-500/30 shadow-xl space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-indigo-800/60 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-indigo-500/20 border border-indigo-400/30 text-indigo-300 rounded-xl">
|
||||
<Sparkles className="w-6 h-6 text-indigo-300 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-extrabold text-base text-white">Smart Next-Payer Advisor</h3>
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-400/30 uppercase tracking-wider">
|
||||
Minimizes Settlements
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-indigo-200">
|
||||
Recommends who should pay next for subscriptions or expenses to naturally offset group debt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenAddExpense && (
|
||||
<button
|
||||
onClick={onOpenAddExpense}
|
||||
className="px-3 py-1.5 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition flex items-center gap-1 shadow-md shrink-0"
|
||||
>
|
||||
<Receipt className="w-3.5 h-3.5" />
|
||||
<span>Log Expense</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenAddSub && (
|
||||
<button
|
||||
onClick={onOpenAddSub}
|
||||
className="px-3 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white font-bold rounded-xl text-xs transition flex items-center gap-1 shadow-md shrink-0"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Sub</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Banner Recommendation */}
|
||||
<div className="p-4 bg-slate-800/80 rounded-xl border border-indigo-400/20 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center space-x-3.5">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-full ${topPayer.avatarColor || 'bg-rose-500'} text-white font-black flex items-center justify-center text-sm ring-4 ring-indigo-500/30 shadow-lg shrink-0`}
|
||||
>
|
||||
{topPayer.memberName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-bold text-indigo-300 tracking-wider block">
|
||||
#1 Recommended Next Payer
|
||||
</span>
|
||||
<div className="text-lg font-black text-white flex items-center gap-2">
|
||||
<span>{topPayer.memberName}</span>
|
||||
{owesMoney ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-md bg-rose-500/20 border border-rose-400/30 text-rose-300 font-bold">
|
||||
Owes ${currentDebtAmount.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-md bg-emerald-500/20 border border-emerald-400/30 text-emerald-300 font-bold">
|
||||
Balanced / Ahead
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 mt-1 max-w-lg leading-relaxed">
|
||||
{owesMoney ? (
|
||||
<>
|
||||
Paying upfront for the next purchase increases <strong>{topPayer.memberName}</strong>'s balance towards $0.00, reducing what they owe to others without requiring manual money transfers!
|
||||
</>
|
||||
) : (
|
||||
<>All group members are currently balanced or owed money. Any member can pay next!</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Debt Offset Box */}
|
||||
{owesMoney && (
|
||||
<div className="p-3 bg-indigo-950/80 rounded-xl border border-indigo-500/30 text-right shrink-0 min-w-[170px]">
|
||||
<span className="text-[10px] uppercase font-bold text-indigo-300 block">Current Debt</span>
|
||||
<span className="text-xl font-black text-rose-400">-${currentDebtAmount.toFixed(2)}</span>
|
||||
<span className="text-[10px] text-emerald-300 block mt-0.5 font-medium">
|
||||
Next payment reduces this!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Simulation & Ranked Queue */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Cost Simulation Card */}
|
||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
|
||||
<span className="text-xs font-extrabold text-white uppercase tracking-wider flex items-center gap-1.5">
|
||||
<TrendingUp className="w-4 h-4 text-indigo-400" />
|
||||
Simulate Next Purchase Impact
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Equal split among {memberCount} members</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-slate-300 font-medium">Estimated Purchase Amount:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{[15, 30, 60, 100].map((cost) => (
|
||||
<button
|
||||
key={cost}
|
||||
onClick={() => setSimulatedCost(cost)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition ${
|
||||
simulatedCost === cost
|
||||
? 'bg-indigo-600 text-white shadow-xs'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
${cost}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-800/60 rounded-xl border border-slate-700/60 text-xs space-y-1.5">
|
||||
<div className="flex justify-between text-slate-300">
|
||||
<span>If {topPayer.memberName} pays ${simulatedCost.toFixed(2)} upfront:</span>
|
||||
<span className="font-bold text-emerald-400">+${netCreditGain.toFixed(2)} net credit</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-300">
|
||||
<span>{topPayer.memberName}'s Share ({memberCount} way split):</span>
|
||||
<span className="font-medium text-slate-400">-${topPayerShare.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="pt-1.5 border-t border-slate-700 flex justify-between font-bold">
|
||||
<span>Simulated New Net Balance:</span>
|
||||
<span className={simulatedNewNet < 0 ? 'text-rose-400' : 'text-emerald-400'}>
|
||||
{simulatedNewNet < 0 ? `-$${Math.abs(simulatedNewNet).toFixed(2)}` : `+$${simulatedNewNet.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{owesMoney && debtReduction > 0 && (
|
||||
<p className="text-[11px] text-emerald-300 bg-emerald-950/40 p-2 rounded-lg border border-emerald-500/20 text-center font-medium">
|
||||
✨ Eliminates <strong>${debtReduction.toFixed(2)}</strong> in settlement payments!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ranked Payer Queue */}
|
||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
|
||||
<span className="text-xs font-extrabold text-white uppercase tracking-wider flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
Ranked Next-Payer Queue
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Priority order to keep group balanced</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-[150px] overflow-y-auto pr-1">
|
||||
{sortedBalances.map((bal, rank) => {
|
||||
const isDebtor = bal.netMonthlyBalance < -0.01;
|
||||
return (
|
||||
<div
|
||||
key={bal.memberId}
|
||||
className={`p-2 rounded-lg flex items-center justify-between text-xs transition ${
|
||||
rank === 0
|
||||
? 'bg-indigo-900/60 border border-indigo-500/40 font-bold text-white'
|
||||
: 'bg-slate-800/50 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-black ${
|
||||
rank === 0 ? 'bg-indigo-500 text-white' : 'bg-slate-700 text-slate-300'
|
||||
}`}>
|
||||
#{rank + 1}
|
||||
</span>
|
||||
<span>{bal.memberName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{isDebtor ? (
|
||||
<span className="text-rose-400 font-bold">
|
||||
-${Math.abs(bal.netMonthlyBalance).toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-400 font-medium">
|
||||
+${bal.netMonthlyBalance.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-400 font-normal">
|
||||
{rank === 0 ? '(Pay Next)' : isDebtor ? '(Owes)' : '(Avoid)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import React, { useState } from 'react';
|
||||
import { OneOffExpense, Member, Group } from '../types';
|
||||
import { calculateExpenseMemberShares } from '../utils/splitCalculator';
|
||||
import {
|
||||
Utensils,
|
||||
ShoppingBag,
|
||||
Car,
|
||||
Ticket,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Trash2,
|
||||
Edit2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Receipt,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
Users,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface OneOffExpenseListProps {
|
||||
expenses: OneOffExpense[];
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
onAddExpense: () => void;
|
||||
onEditExpense: (expense: OneOffExpense) => void;
|
||||
onDeleteExpense: (expenseId: string) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
Dining: Utensils,
|
||||
Groceries: ShoppingBag,
|
||||
Travel: Car,
|
||||
Entertainment: Ticket,
|
||||
Utilities: Zap,
|
||||
Events: Sparkles,
|
||||
Other: Receipt,
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Dining: 'bg-amber-100 text-amber-900 border-amber-300',
|
||||
Groceries: 'bg-emerald-100 text-emerald-900 border-emerald-300',
|
||||
Travel: 'bg-sky-100 text-sky-900 border-sky-300',
|
||||
Entertainment: 'bg-purple-100 text-purple-900 border-purple-300',
|
||||
Events: 'bg-pink-100 text-pink-900 border-pink-300',
|
||||
Utilities: 'bg-indigo-100 text-indigo-900 border-indigo-300',
|
||||
Other: 'bg-slate-100 text-slate-800 border-slate-300',
|
||||
};
|
||||
|
||||
export const OneOffExpenseList: React.FC<OneOffExpenseListProps> = ({
|
||||
expenses,
|
||||
members,
|
||||
groups,
|
||||
onAddExpense,
|
||||
onEditExpense,
|
||||
onDeleteExpense,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
||||
const [expandedExpenseId, setExpandedExpenseId] = useState<string | null>(
|
||||
expenses[0]?.id || null
|
||||
);
|
||||
|
||||
const categories = ['ALL', 'Dining', 'Groceries', 'Travel', 'Entertainment', 'Events', 'Utilities', 'Other'];
|
||||
|
||||
const filteredExpenses = expenses.filter((exp) => {
|
||||
const matchesSearch =
|
||||
exp.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(exp.notes && exp.notes.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchesCategory = selectedCategory === 'ALL' || exp.category === selectedCategory;
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const totalExpenseOutlay = expenses.reduce((acc, curr) => acc + curr.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Banner / Metrics */}
|
||||
<div className="bg-gradient-to-r from-amber-900 via-slate-900 to-indigo-950 text-white p-6 rounded-2xl border border-slate-800 shadow-md flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Receipt className="w-5 h-5 text-amber-400" />
|
||||
<h2 className="text-xl font-bold">One-Off Shared Charges</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 max-w-xl">
|
||||
Track single event expenses like group dinners, trip supplies, concert tickets, or shared ride fares that shouldn't recur monthly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 bg-white/10 p-3 rounded-xl border border-white/10 backdrop-blur-xs">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-300 block font-semibold">Total Logged Charges</span>
|
||||
<span className="text-xl font-black text-amber-400">${totalExpenseOutlay.toFixed(2)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddExpense}
|
||||
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 active:scale-[0.98] text-slate-950 font-bold text-xs rounded-xl transition shadow-sm flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Log Charge</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action & Filter Bar */}
|
||||
<div className="bg-white p-4 rounded-2xl border border-slate-200/90 shadow-xs flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search one-off charges by title or notes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm font-medium focus:bg-white focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 w-full sm:w-auto overflow-x-auto pb-1 sm:pb-0">
|
||||
<div className="flex items-center space-x-1.5 bg-slate-50 px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-medium">
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="bg-transparent text-slate-700 font-semibold focus:outline-none cursor-pointer"
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c === 'ALL' ? 'All Categories' : c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expenses Cards */}
|
||||
{filteredExpenses.length === 0 ? (
|
||||
<div className="bg-white p-12 rounded-2xl border border-slate-200 text-center space-y-3">
|
||||
<div className="w-12 h-12 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<Receipt className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900 text-sm">No One-Off Charges Found</h3>
|
||||
<p className="text-xs text-slate-500 max-w-md mx-auto">
|
||||
Log single-event shared expenses like dinner bills, shared grocery runs, or event tickets to calculate exact participant splits.
|
||||
</p>
|
||||
<button
|
||||
onClick={onAddExpense}
|
||||
className="mt-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-semibold transition inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Log First Expense</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredExpenses.map((exp) => {
|
||||
const payer = members.find((m) => m.id === exp.payerId);
|
||||
const isExpanded = expandedExpenseId === exp.id;
|
||||
const CategoryIcon = CATEGORY_ICONS[exp.category || 'Other'] || Receipt;
|
||||
const categoryBadgeStyle = CATEGORY_COLORS[exp.category || 'Other'] || CATEGORY_COLORS.Other;
|
||||
const { shares, explanations } = calculateExpenseMemberShares(exp, members, groups);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="bg-white rounded-2xl border border-slate-200/90 shadow-xs hover:border-slate-300 transition overflow-hidden"
|
||||
>
|
||||
{/* Main Summary Header Row */}
|
||||
<div className="p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-start space-x-3.5">
|
||||
<div className={`p-2.5 rounded-xl border ${categoryBadgeStyle} shrink-0 mt-0.5`}>
|
||||
<CategoryIcon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
<h3 className="font-bold text-slate-900 text-sm">{exp.title}</h3>
|
||||
<span className={`px-2 py-0.5 text-[10px] font-bold border rounded-md ${categoryBadgeStyle}`}>
|
||||
{exp.category || 'Other'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-[10px] font-semibold bg-slate-100 text-slate-600 rounded-md border border-slate-200">
|
||||
{exp.splitModel === 'EQUAL_ALL' && 'Equal Split (All)'}
|
||||
{exp.splitModel === 'EQUAL_SELECTED' && 'Selected Attendees'}
|
||||
{exp.splitModel === 'CUSTOM' && 'Custom Split'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-xs text-slate-500 flex-wrap gap-y-1">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>{exp.date}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<span className="text-slate-400">Payer:</span>
|
||||
<span className="font-semibold text-slate-700">{payer?.name || 'Unknown'}</span>
|
||||
{payer && (
|
||||
<div className={`w-4 h-4 rounded-full ${payer.avatarColor} text-[9px] flex items-center justify-center text-white font-bold`}>
|
||||
{payer.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side Amount & Controls */}
|
||||
<div className="flex items-center justify-between sm:justify-end space-x-4 border-t sm:border-t-0 pt-3 sm:pt-0 border-slate-100">
|
||||
<div className="text-left sm:text-right">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-400 block font-semibold">Total Amount</span>
|
||||
<span className="text-lg font-black text-slate-900">${exp.amount.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => onEditExpense(exp)}
|
||||
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-slate-100 rounded-xl transition"
|
||||
title="Edit Charge"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteExpense(exp.id)}
|
||||
className="p-2 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-xl transition"
|
||||
title="Delete Charge"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedExpenseId(isExpanded ? null : exp.id)}
|
||||
className="p-2 text-slate-500 hover:bg-slate-100 rounded-xl transition flex items-center gap-1 text-xs font-semibold"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Member Breakdown Tray */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 py-4 bg-slate-50/80 border-t border-slate-200/80 space-y-3">
|
||||
{exp.notes && (
|
||||
<p className="text-xs text-slate-600 bg-white p-2.5 rounded-xl border border-slate-200/80 italic">
|
||||
"{exp.notes}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">
|
||||
Per-Member Split Calculation
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{members.map((m) => {
|
||||
const share = shares[m.id] || 0;
|
||||
const explanation = explanations[m.id] || '';
|
||||
const isPayer = m.id === exp.payerId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`p-2.5 rounded-xl border transition ${
|
||||
share > 0
|
||||
? 'bg-white border-slate-200'
|
||||
: 'bg-slate-100/60 border-slate-200/60 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-5 h-5 rounded-full ${m.avatarColor} text-[10px] flex items-center justify-center font-bold text-white shrink-0`}>
|
||||
{m.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-800">{m.name}</span>
|
||||
</div>
|
||||
{isPayer && (
|
||||
<span className="px-1.5 py-0.5 text-[9px] bg-amber-100 text-amber-900 border border-amber-300 rounded font-bold">
|
||||
Payer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mt-1.5">
|
||||
<span className="text-sm font-black text-slate-900">
|
||||
${share.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium truncate max-w-[140px]" title={explanation}>
|
||||
{explanation}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,581 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
OneOffExpense,
|
||||
Member,
|
||||
Group,
|
||||
SplitModel,
|
||||
SplitRule,
|
||||
CustomShareType,
|
||||
} from '../types';
|
||||
import { calculateExpenseMemberShares } from '../utils/splitCalculator';
|
||||
import { X, Utensils, ShoppingBag, Car, Ticket, Zap, Check, Users, UserCheck, Sparkles, DollarSign, Calendar } from 'lucide-react';
|
||||
|
||||
interface OneOffExpenseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (expense: OneOffExpense) => void;
|
||||
initialData?: OneOffExpense | null;
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
const EXPENSE_PRESETS = [
|
||||
{ title: 'Group Dinner', category: 'Dining', icon: Utensils, defaultAmount: 120 },
|
||||
{ title: 'Grocery Run', category: 'Groceries', icon: ShoppingBag, defaultAmount: 85 },
|
||||
{ title: 'Ride Share / Taxi', category: 'Travel', icon: Car, defaultAmount: 45 },
|
||||
{ title: 'Event / Concert Tickets', category: 'Entertainment', icon: Ticket, defaultAmount: 150 },
|
||||
{ title: 'Shared Utilities / Bill', category: 'Utilities', icon: Zap, defaultAmount: 60 },
|
||||
];
|
||||
|
||||
export const OneOffExpenseModal: React.FC<OneOffExpenseModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialData,
|
||||
members,
|
||||
groups,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [title, setTitle] = useState(initialData?.title || '');
|
||||
const [category, setCategory] = useState<OneOffExpense['category']>(initialData?.category || 'Dining');
|
||||
const [amount, setAmount] = useState<number>(initialData?.amount || 50.0);
|
||||
const [payerId, setPayerId] = useState<string>(initialData?.payerId || members[0]?.id || '');
|
||||
const [date, setDate] = useState(initialData?.date || new Date().toISOString().split('T')[0]);
|
||||
const [notes, setNotes] = useState(initialData?.notes || '');
|
||||
|
||||
// Split Model State
|
||||
const [splitModel, setSplitModel] = useState<SplitModel>(initialData?.splitModel || 'EQUAL_ALL');
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(
|
||||
initialData?.selectedMemberIds || members.map((m) => m.id)
|
||||
);
|
||||
|
||||
// Custom Rules State
|
||||
const [customShareType, setCustomShareType] = useState<CustomShareType>(
|
||||
initialData?.customRules?.[0]?.shareType || 'percentage'
|
||||
);
|
||||
const [customRules, setCustomRules] = useState<SplitRule[]>(
|
||||
initialData?.customRules || [
|
||||
{ targetType: 'member', targetId: members[0]?.id || '', value: 50, shareType: 'percentage' },
|
||||
{ targetType: 'member', targetId: members[1]?.id || '', value: 50, shareType: 'percentage' },
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setTitle(initialData.title);
|
||||
setCategory(initialData.category || 'Dining');
|
||||
setAmount(initialData.amount);
|
||||
setPayerId(initialData.payerId);
|
||||
setDate(initialData.date);
|
||||
setNotes(initialData.notes || '');
|
||||
setSplitModel(initialData.splitModel);
|
||||
setSelectedMemberIds(initialData.selectedMemberIds || members.map((m) => m.id));
|
||||
if (initialData.customRules && initialData.customRules.length > 0) {
|
||||
setCustomRules(initialData.customRules);
|
||||
setCustomShareType(initialData.customRules[0].shareType || 'percentage');
|
||||
}
|
||||
} else {
|
||||
// Default reset
|
||||
setTitle('');
|
||||
setCategory('Dining');
|
||||
setAmount(60.0);
|
||||
setPayerId(members[0]?.id || '');
|
||||
setDate(new Date().toISOString().split('T')[0]);
|
||||
setNotes('');
|
||||
setSplitModel('EQUAL_ALL');
|
||||
setSelectedMemberIds(members.map((m) => m.id));
|
||||
}
|
||||
}, [initialData, members]);
|
||||
|
||||
// Live preview calculation
|
||||
const mockExpense: OneOffExpense = {
|
||||
id: initialData?.id || 'preview-exp',
|
||||
title: title || 'One-Off Expense Preview',
|
||||
amount: amount || 0,
|
||||
date,
|
||||
payerId,
|
||||
category,
|
||||
splitModel,
|
||||
selectedMemberIds,
|
||||
customRules: customRules.map((r) => ({ ...r, shareType: customShareType })),
|
||||
notes,
|
||||
};
|
||||
|
||||
const { shares, explanations } = calculateExpenseMemberShares(mockExpense, members, groups);
|
||||
|
||||
const toggleMemberSelection = (memberId: string) => {
|
||||
if (selectedMemberIds.includes(memberId)) {
|
||||
setSelectedMemberIds(selectedMemberIds.filter((id) => id !== memberId));
|
||||
} else {
|
||||
setSelectedMemberIds([...selectedMemberIds, memberId]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroupSelection = (groupId: string) => {
|
||||
const groupMemberIds = members.filter((m) => m.groupId === groupId).map((m) => m.id);
|
||||
const allSelected = groupMemberIds.every((id) => selectedMemberIds.includes(id));
|
||||
|
||||
if (allSelected) {
|
||||
setSelectedMemberIds(selectedMemberIds.filter((id) => !groupMemberIds.includes(id)));
|
||||
} else {
|
||||
const combined = new Set([...selectedMemberIds, ...groupMemberIds]);
|
||||
setSelectedMemberIds(Array.from(combined));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = (preset: typeof EXPENSE_PRESETS[0]) => {
|
||||
setTitle(preset.title);
|
||||
setCategory(preset.category as OneOffExpense['category']);
|
||||
if (!initialData) {
|
||||
setAmount(preset.defaultAmount);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (amount <= 0) return;
|
||||
|
||||
const expense: OneOffExpense = {
|
||||
id: initialData?.id || `exp-${Date.now()}`,
|
||||
title: title.trim(),
|
||||
amount: Number(amount),
|
||||
date,
|
||||
payerId,
|
||||
category,
|
||||
splitModel,
|
||||
selectedMemberIds: splitModel === 'EQUAL_SELECTED' ? selectedMemberIds : undefined,
|
||||
customRules: splitModel === 'CUSTOM' ? customRules.map((r) => ({ ...r, shareType: customShareType })) : undefined,
|
||||
notes: notes.trim(),
|
||||
};
|
||||
|
||||
onSave(expense);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 backdrop-blur-xs p-4 overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 max-w-2xl w-full my-8 overflow-hidden flex flex-col max-h-[90vh]">
|
||||
{/* Header */}
|
||||
<div className="p-5 bg-slate-900 text-white flex items-center justify-between border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center text-white shadow-md">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">
|
||||
{initialData ? 'Edit One-Off Expense' : 'Log One-Off Shared Charge'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Split single events like group dinners, travel expenses, or shared grocery runs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Form Content */}
|
||||
<form onSubmit={handleSave} className="p-6 space-y-6 overflow-y-auto flex-1 text-slate-800">
|
||||
{/* Quick Presets (Only when creating new) */}
|
||||
{!initialData && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||
Quick Presets
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXPENSE_PRESETS.map((p) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<button
|
||||
key={p.title}
|
||||
type="button"
|
||||
onClick={() => handleApplyPreset(p)}
|
||||
className="px-3 py-1.5 bg-slate-100 hover:bg-indigo-50 hover:text-indigo-700 text-slate-700 border border-slate-200 rounded-xl text-xs font-semibold transition flex items-center gap-1.5"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{p.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Charge Info */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Charge / Event Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Dinner at Mario's, Ski Trip Gas, Concert Tickets..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Total Amount Paid ($) *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2.5 text-slate-400 text-sm font-bold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
|
||||
className="w-full pl-7 pr-3.5 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Date *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="w-4 h-4 absolute left-3 top-3 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full pl-9 pr-3.5 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs font-semibold text-slate-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as OneOffExpense['category'])}
|
||||
className="w-full px-3.5 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
||||
>
|
||||
<option value="Dining">Dining & Drinks</option>
|
||||
<option value="Groceries">Groceries & Food</option>
|
||||
<option value="Travel">Travel & Transportation</option>
|
||||
<option value="Entertainment">Entertainment & Outings</option>
|
||||
<option value="Events">Events & Celebrations</option>
|
||||
<option value="Utilities">Shared Utilities & Supplies</option>
|
||||
<option value="Other">Other One-Off Charge</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Who Paid Upfront? *
|
||||
</label>
|
||||
<select
|
||||
value={payerId}
|
||||
onChange={(e) => setPayerId(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
||||
>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} {m.groupId ? `(${groups.find((g) => g.id === m.groupId)?.name || 'Family'})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Model Chooser */}
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<label className="block text-xs font-bold text-slate-800 uppercase tracking-wider mb-2">
|
||||
Split Model & Member Selection
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitModel('EQUAL_ALL')}
|
||||
className={`p-3 rounded-xl border text-left transition flex flex-col justify-between ${
|
||||
splitModel === 'EQUAL_ALL'
|
||||
? 'bg-indigo-50/80 border-indigo-500 text-indigo-950 ring-1 ring-indigo-500'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Users className="w-4 h-4 text-indigo-600" />
|
||||
{splitModel === 'EQUAL_ALL' && <Check className="w-4 h-4 text-indigo-600" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold">Equal (All)</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5">Split among all group members</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitModel('EQUAL_SELECTED')}
|
||||
className={`p-3 rounded-xl border text-left transition flex flex-col justify-between ${
|
||||
splitModel === 'EQUAL_SELECTED'
|
||||
? 'bg-indigo-50/80 border-indigo-500 text-indigo-950 ring-1 ring-indigo-500'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<UserCheck className="w-4 h-4 text-indigo-600" />
|
||||
{splitModel === 'EQUAL_SELECTED' && <Check className="w-4 h-4 text-indigo-600" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold">Selected Members</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5">Only split among attendees</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitModel('CUSTOM')}
|
||||
className={`p-3 rounded-xl border text-left transition flex flex-col justify-between ${
|
||||
splitModel === 'CUSTOM'
|
||||
? 'bg-indigo-50/80 border-indigo-500 text-indigo-950 ring-1 ring-indigo-500'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Sparkles className="w-4 h-4 text-indigo-600" />
|
||||
{splitModel === 'CUSTOM' && <Check className="w-4 h-4 text-indigo-600" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold">Custom Split</div>
|
||||
<div className="text-[10px] text-slate-500 leading-tight mt-0.5">Custom %, weight, or fixed $</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EQUAL_SELECTED Details */}
|
||||
{splitModel === 'EQUAL_SELECTED' && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200 space-y-3">
|
||||
<div className="text-xs font-bold text-slate-700 flex items-center justify-between">
|
||||
<span>Select Attending Members / Families:</span>
|
||||
<span className="text-[11px] text-indigo-600 font-semibold">
|
||||
{selectedMemberIds.length} members selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Family Toggles */}
|
||||
<div className="flex flex-wrap gap-2 pb-2 border-b border-slate-200">
|
||||
{groups.map((grp) => {
|
||||
const gMemberIds = members.filter((m) => m.groupId === grp.id).map((m) => m.id);
|
||||
const allSelected = gMemberIds.length > 0 && gMemberIds.every((id) => selectedMemberIds.includes(id));
|
||||
return (
|
||||
<button
|
||||
key={grp.id}
|
||||
type="button"
|
||||
onClick={() => toggleGroupSelection(grp.id)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-semibold border transition ${
|
||||
allSelected
|
||||
? 'bg-indigo-600 text-white border-indigo-600'
|
||||
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{allSelected ? '✓ ' : '+ '} {grp.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Individual Member List */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{members.map((m) => {
|
||||
const isSelected = selectedMemberIds.includes(m.id);
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => toggleMemberSelection(m.id)}
|
||||
className={`p-2 rounded-xl text-xs font-semibold flex items-center justify-between border transition ${
|
||||
isSelected
|
||||
? 'bg-indigo-50 text-indigo-900 border-indigo-300 ring-1 ring-indigo-300'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 truncate">
|
||||
<div className={`w-5 h-5 rounded-full ${m.avatarColor} text-[10px] flex items-center justify-center font-bold text-white shrink-0`}>
|
||||
{m.name.charAt(0)}
|
||||
</div>
|
||||
<span className="truncate">{m.name}</span>
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-indigo-600 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CUSTOM Split Details */}
|
||||
{splitModel === 'CUSTOM' && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-slate-700">Custom Share Rule Type:</label>
|
||||
<select
|
||||
value={customShareType}
|
||||
onChange={(e) => setCustomShareType(e.target.value as CustomShareType)}
|
||||
className="px-2.5 py-1 bg-white border border-slate-200 rounded-lg text-xs font-semibold text-slate-800"
|
||||
>
|
||||
<option value="percentage">Percentage (%)</option>
|
||||
<option value="fixed">Fixed Dollar ($)</option>
|
||||
<option value="weight">Parts / Weight</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{customRules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center space-x-2 bg-white p-2 rounded-xl border border-slate-200">
|
||||
<select
|
||||
value={rule.targetType}
|
||||
onChange={(e) => {
|
||||
const newRules = [...customRules];
|
||||
newRules[idx].targetType = e.target.value as 'member' | 'group';
|
||||
newRules[idx].targetId =
|
||||
e.target.value === 'member' ? members[0]?.id || '' : groups[0]?.id || '';
|
||||
setCustomRules(newRules);
|
||||
}}
|
||||
className="px-2 py-1 bg-slate-50 border border-slate-200 rounded-lg text-xs font-semibold"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="group">Family / Group</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={rule.targetId}
|
||||
onChange={(e) => {
|
||||
const newRules = [...customRules];
|
||||
newRules[idx].targetId = e.target.value;
|
||||
setCustomRules(newRules);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 bg-slate-50 border border-slate-200 rounded-lg text-xs font-semibold truncate"
|
||||
>
|
||||
{rule.targetType === 'member'
|
||||
? members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
: groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={rule.value}
|
||||
onChange={(e) => {
|
||||
const newRules = [...customRules];
|
||||
newRules[idx].value = parseFloat(e.target.value) || 0;
|
||||
setCustomRules(newRules);
|
||||
}}
|
||||
className="w-20 px-2 py-1 bg-slate-50 border border-slate-200 rounded-lg text-xs font-bold text-slate-900"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomRules(customRules.filter((_, i) => i !== idx))}
|
||||
className="p-1 text-slate-400 hover:text-rose-600 rounded-lg transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCustomRules([
|
||||
...customRules,
|
||||
{ targetType: 'member', targetId: members[0]?.id || '', value: 0, shareType: customShareType },
|
||||
])
|
||||
}
|
||||
className="w-full py-1.5 bg-slate-200/80 hover:bg-slate-200 text-slate-700 rounded-xl text-xs font-semibold transition"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Breakdown Preview Box */}
|
||||
<div className="p-4 bg-indigo-950 text-white rounded-xl space-y-2 border border-indigo-800 shadow-inner">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-indigo-300 uppercase tracking-wider">
|
||||
<span>Calculated Per-Member Shares</span>
|
||||
<span>Total Charge: ${amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 pt-1">
|
||||
{members.map((m) => {
|
||||
const share = shares[m.id] || 0;
|
||||
const isPayer = m.id === payerId;
|
||||
return (
|
||||
<div key={m.id} className="p-2 bg-indigo-900/60 rounded-lg border border-indigo-700/60 text-xs">
|
||||
<div className="flex items-center justify-between text-slate-300 font-medium truncate">
|
||||
<span className="truncate">{m.name}</span>
|
||||
{isPayer && (
|
||||
<span className="text-[9px] px-1 py-0.5 bg-amber-500/30 text-amber-300 rounded font-bold">
|
||||
PAID
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-black text-white mt-0.5">
|
||||
${share.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||||
Notes / Memo (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="e.g. Split equally among 4 attendees, includes tip..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full px-3.5 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="pt-4 border-t border-slate-200 flex items-center justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-xs font-semibold transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 active:scale-[0.98] text-white rounded-xl text-xs font-bold transition shadow-sm"
|
||||
>
|
||||
{initialData ? 'Save Changes' : 'Add Charge'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { SettlementRecord, Member } from '../types';
|
||||
import { RefreshCw, Plus, Calendar, ArrowRight, Trash2 } from 'lucide-react';
|
||||
|
||||
interface SettlementLogProps {
|
||||
settlements: SettlementRecord[];
|
||||
members: Member[];
|
||||
onOpenSettleModal: () => void;
|
||||
onDeleteSettlement: (id: string) => void;
|
||||
}
|
||||
|
||||
export const SettlementLog: React.FC<SettlementLogProps> = ({
|
||||
settlements,
|
||||
members,
|
||||
onOpenSettleModal,
|
||||
onDeleteSettlement,
|
||||
}) => {
|
||||
const getMemberName = (id: string) => members.find((m) => m.id === id)?.name || 'Unknown';
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-indigo-600" />
|
||||
Settlement History Log
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500">Record of direct peer-to-peer payments made between family members.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenSettleModal}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-semibold shadow-sm transition flex items-center gap-1.5 self-start sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Record New Settlement</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settlements.length === 0 ? (
|
||||
<div className="p-12 text-center text-slate-400 space-y-2">
|
||||
<RefreshCw className="w-8 h-8 text-slate-300 mx-auto" />
|
||||
<p className="text-sm font-semibold">No recorded settlements yet</p>
|
||||
<p className="text-xs text-slate-500">When members make payments via Venmo, Zelle, or cash, record them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{settlements.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="p-4 bg-slate-50 hover:bg-slate-100/80 rounded-xl border border-slate-200/80 transition flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-100 text-emerald-800 font-bold flex items-center justify-center text-xs">
|
||||
$
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 font-bold text-slate-900 text-sm">
|
||||
<span>{getMemberName(s.fromMemberId)}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>{getMemberName(s.toMemberId)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-slate-400" />
|
||||
{s.date}
|
||||
</span>
|
||||
{s.note && <span>• {s.note}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-base font-black text-emerald-600">${s.amount.toFixed(2)}</span>
|
||||
<button
|
||||
onClick={() => onDeleteSettlement(s.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-rose-600 transition"
|
||||
title="Delete record"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Member, SettlementRecord } from '../types';
|
||||
import { X, CheckCircle2, ArrowRight, DollarSign, Calendar } from 'lucide-react';
|
||||
|
||||
interface SettlementModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaveSettlement: (record: Omit<SettlementRecord, 'id'>) => void;
|
||||
members: Member[];
|
||||
defaultFromId?: string;
|
||||
defaultToId?: string;
|
||||
defaultAmount?: number;
|
||||
}
|
||||
|
||||
export const SettlementModal: React.FC<SettlementModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSaveSettlement,
|
||||
members,
|
||||
defaultFromId,
|
||||
defaultToId,
|
||||
defaultAmount,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [fromId, setFromId] = useState(defaultFromId || members[0]?.id || '');
|
||||
const [toId, setToId] = useState(defaultToId || members[1]?.id || '');
|
||||
const [amount, setAmount] = useState<number>(defaultAmount || 10.0);
|
||||
const [date, setDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultFromId) setFromId(defaultFromId);
|
||||
if (defaultToId) setToId(defaultToId);
|
||||
if (defaultAmount) setAmount(defaultAmount);
|
||||
}, [defaultFromId, defaultToId, defaultAmount]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!fromId || !toId || fromId === toId || amount <= 0) return;
|
||||
|
||||
onSaveSettlement({
|
||||
fromMemberId: fromId,
|
||||
toMemberId: toId,
|
||||
amount,
|
||||
date,
|
||||
note: note.trim() || undefined,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fromMember = members.find((m) => m.id === fromId);
|
||||
const toMember = members.find((m) => m.id === toId);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full border border-slate-200 shadow-2xl p-6 space-y-5 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
||||
<h3 className="font-bold text-slate-900 text-base">Record Settlement Payment</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:bg-slate-100">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{/* From & To Visual Flow */}
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex items-center justify-between">
|
||||
<div className="text-center flex-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Payer (Who Paid)</span>
|
||||
<select
|
||||
value={fromId}
|
||||
onChange={(e) => setFromId(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-1.5 text-xs font-bold text-slate-800 text-center"
|
||||
>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="px-3 flex flex-col items-center text-slate-400">
|
||||
<ArrowRight className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
|
||||
<div className="text-center flex-1">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Recipient (Who Received)</span>
|
||||
<select
|
||||
value={toId}
|
||||
onChange={(e) => setToId(e.target.value)}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg p-1.5 text-xs font-bold text-slate-800 text-center"
|
||||
>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Settlement Amount ($)</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Date Paid</label>
|
||||
<div className="relative">
|
||||
<Calendar className="w-4 h-4 text-slate-400 absolute left-3 top-2.5" />
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-700 mb-1">Note / Reference (e.g. Venmo / Zelle / Cash)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Venmo payment for July Netflix"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2 pt-3 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-700 hover:bg-slate-100 rounded-xl font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={fromId === toId || amount <= 0}
|
||||
className="px-5 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-bold shadow-md shadow-emerald-600/20 disabled:opacity-50"
|
||||
>
|
||||
Confirm Settlement
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MemberBalance, DebtTransaction, Subscription, OneOffExpense } from '../types';
|
||||
import { X, Copy, Check, Share2, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface ShareReportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
balances: MemberBalance[];
|
||||
debts: DebtTransaction[];
|
||||
subscriptions: Subscription[];
|
||||
oneOffExpenses?: OneOffExpense[];
|
||||
}
|
||||
|
||||
export const ShareReportModal: React.FC<ShareReportModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
balances,
|
||||
debts,
|
||||
subscriptions,
|
||||
oneOffExpenses = [],
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Generate plain text report
|
||||
const generateTextReport = () => {
|
||||
const activeSubs = subscriptions.filter((s) => s.status === 'active');
|
||||
const totalSubOutlay = activeSubs.reduce(
|
||||
(acc, s) => acc + (s.billingCycle === 'yearly' ? s.cost / 12 : s.cost),
|
||||
0
|
||||
);
|
||||
const totalOneOffOutlay = oneOffExpenses.reduce((acc, e) => acc + e.amount, 0);
|
||||
|
||||
let report = `📊 *SubSplit Shared Balance & Settlement Summary*\n`;
|
||||
report += `Subscriptions Outlay: $${totalSubOutlay.toFixed(2)}/month (${activeSubs.length} active subs)\n`;
|
||||
if (oneOffExpenses.length > 0) {
|
||||
report += `One-Off Charges Outlay: $${totalOneOffOutlay.toFixed(2)} (${oneOffExpenses.length} shared events)\n`;
|
||||
}
|
||||
report += `\n`;
|
||||
|
||||
report += `💳 *Who Owes Whom (Net Settlements):*\n`;
|
||||
if (debts.length === 0) {
|
||||
report += `✅ All accounts are fully settled up! No outstanding debts.\n\n`;
|
||||
} else {
|
||||
debts.forEach((d) => {
|
||||
report += `• *${d.fromMemberName}* owes *${d.toMemberName}*: *$${d.amount.toFixed(2)}*\n`;
|
||||
});
|
||||
report += `\n`;
|
||||
}
|
||||
|
||||
report += `👤 *Individual Member Balances:* \n`;
|
||||
balances.forEach((b) => {
|
||||
const netStr =
|
||||
b.netMonthlyBalance > 0
|
||||
? `+${b.netMonthlyBalance.toFixed(2)} (credited)`
|
||||
: b.netMonthlyBalance < 0
|
||||
? `-${Math.abs(b.netMonthlyBalance).toFixed(2)} (debited)`
|
||||
: `$0.00 (settled)`;
|
||||
|
||||
const totalOwedCombined = b.totalOwedMonthly + b.totalOwedOneOff;
|
||||
report += `• ${b.memberName}: Total Fair Share: $${totalOwedCombined.toFixed(2)} | Net: ${netStr}\n`;
|
||||
});
|
||||
|
||||
report += `\nSent via SubSplit App 🚀`;
|
||||
return report;
|
||||
};
|
||||
|
||||
const reportText = generateTextReport();
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(reportText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full border border-slate-200 shadow-2xl p-6 space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageSquare className="w-5 h-5 text-indigo-600" />
|
||||
<h3 className="font-bold text-slate-900 text-base">Export Chat Settlement Report</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:bg-slate-100">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Copy this formatted summary to share directly in WhatsApp, iMessage, or email family group chats:
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-900 text-slate-200 p-4 rounded-xl text-xs font-mono whitespace-pre-wrap border border-slate-800 max-h-72 overflow-y-auto leading-relaxed">
|
||||
{reportText}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
|
||||
<span className="text-[11px] text-slate-400">Ready to paste in chat</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-700 hover:bg-slate-100 rounded-xl text-xs font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold shadow-md shadow-indigo-600/20 flex items-center gap-1.5"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-300" /> : <Copy className="w-4 h-4" />}
|
||||
<span>{copied ? 'Copied to Clipboard!' : 'Copy Summary Report'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Subscription, Member, Group } from '../types';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
CreditCard,
|
||||
Calendar,
|
||||
UserCheck,
|
||||
Users,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Calculator,
|
||||
Pause,
|
||||
Play,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SubscriptionListProps {
|
||||
subscriptions: Subscription[];
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
onAddSub: () => void;
|
||||
onEditSub: (sub: Subscription) => void;
|
||||
onDeleteSub: (subId: string) => void;
|
||||
onToggleStatus: (subId: string) => void;
|
||||
onOpenBreakdownModal: (sub: Subscription) => void;
|
||||
}
|
||||
|
||||
export const SubscriptionList: React.FC<SubscriptionListProps> = ({
|
||||
subscriptions,
|
||||
members,
|
||||
groups,
|
||||
onAddSub,
|
||||
onEditSub,
|
||||
onDeleteSub,
|
||||
onToggleStatus,
|
||||
onOpenBreakdownModal,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('ALL');
|
||||
const [splitFilter, setSplitFilter] = useState<string>('ALL');
|
||||
|
||||
const categories = ['ALL', 'Streaming', 'Household', 'Services', 'Gaming', 'Fitness', 'Software', 'Other'];
|
||||
|
||||
const filteredSubs = subscriptions.filter((s) => {
|
||||
const matchesSearch =
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(s.notes && s.notes.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesCat = categoryFilter === 'ALL' || s.category === categoryFilter;
|
||||
const matchesSplit = splitFilter === 'ALL' || s.splitModel === splitFilter;
|
||||
|
||||
return matchesSearch && matchesCat && matchesSplit;
|
||||
});
|
||||
|
||||
const getPayerName = (payerId: string) => {
|
||||
return members.find((m) => m.id === payerId)?.name || 'Unknown';
|
||||
};
|
||||
|
||||
const getSplitBadge = (sub: Subscription) => {
|
||||
switch (sub.splitModel) {
|
||||
case 'EQUAL_ALL':
|
||||
return (
|
||||
<span className="px-2.5 py-1 bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-md text-[11px] font-semibold flex items-center gap-1">
|
||||
<Users className="w-3 h-3 text-emerald-600" />
|
||||
Equal (All Members)
|
||||
</span>
|
||||
);
|
||||
case 'EQUAL_SELECTED':
|
||||
return (
|
||||
<span className="px-2.5 py-1 bg-sky-50 text-sky-700 border border-sky-200 rounded-md text-[11px] font-semibold flex items-center gap-1">
|
||||
<UserCheck className="w-3 h-3 text-sky-600" />
|
||||
Equal ({sub.selectedMemberIds?.length || 0} Selected)
|
||||
</span>
|
||||
);
|
||||
case 'CUSTOM':
|
||||
return (
|
||||
<span className="px-2.5 py-1 bg-purple-50 text-purple-700 border border-purple-200 rounded-md text-[11px] font-semibold flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-purple-600" />
|
||||
Custom Split Logic
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Action Bar */}
|
||||
<div className="bg-white p-4 rounded-2xl border border-slate-200/90 shadow-xs flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search subscriptions by name, category, or notes..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-50/80 border border-slate-200 rounded-xl text-xs sm:text-sm focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center space-x-1.5 bg-slate-50/80 px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-medium">
|
||||
<Filter className="w-3.5 h-3.5 text-slate-400" />
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="bg-transparent text-slate-700 font-semibold focus:outline-none cursor-pointer"
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c === 'ALL' ? 'All Categories' : c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Split Model Filter */}
|
||||
<div className="flex items-center space-x-1.5 bg-slate-50/80 px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-medium">
|
||||
<Layers className="w-3.5 h-3.5 text-slate-400" />
|
||||
<select
|
||||
value={splitFilter}
|
||||
onChange={(e) => setSplitFilter(e.target.value)}
|
||||
className="bg-transparent text-slate-700 font-semibold focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="ALL">All Split Models</option>
|
||||
<option value="EQUAL_ALL">Equal (All)</option>
|
||||
<option value="EQUAL_SELECTED">Equal (Selected)</option>
|
||||
<option value="CUSTOM">Custom Weights/Percentages</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onAddSub}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 active:scale-[0.98] text-white rounded-xl text-xs font-semibold transition flex items-center gap-1.5 shadow-xs shadow-indigo-600/20"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Subscription</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subscription Cards Grid */}
|
||||
{filteredSubs.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-12 text-center border border-slate-200 space-y-3">
|
||||
<CreditCard className="w-10 h-10 text-slate-300 mx-auto" />
|
||||
<h3 className="text-base font-bold text-slate-800">No subscriptions found</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
Try adjusting your search terms or filters, or create a new subscription to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={onAddSub}
|
||||
className="mt-2 px-4 py-2 bg-indigo-600 text-white rounded-xl text-xs font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add Subscription
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filteredSubs.map((sub) => {
|
||||
const payerName = getPayerName(sub.payerId);
|
||||
const isYearly = sub.billingCycle === 'yearly';
|
||||
const monthlyPrice = isYearly ? sub.cost / 12 : sub.cost;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className={`bg-white rounded-2xl p-5 border transition-all shadow-sm flex flex-col justify-between space-y-4 ${
|
||||
sub.status === 'paused' ? 'opacity-60 border-slate-200' : 'border-slate-200/90 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-md inline-block mb-1">
|
||||
{sub.category}
|
||||
</span>
|
||||
<h3 className="font-bold text-slate-900 text-base leading-snug">{sub.name}</h3>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onToggleStatus(sub.id)}
|
||||
className={`p-1.5 rounded-lg text-xs font-semibold transition ${
|
||||
sub.status === 'active'
|
||||
? 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
title={sub.status === 'active' ? 'Pause Subscription' : 'Activate Subscription'}
|
||||
>
|
||||
{sub.status === 'active' ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notes if present */}
|
||||
{sub.notes && <p className="text-xs text-slate-500 line-clamp-2">{sub.notes}</p>}
|
||||
</div>
|
||||
|
||||
{/* Price & Billing Info */}
|
||||
<div className="bg-slate-50/80 p-3 rounded-xl border border-slate-100 space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span className="text-xl font-black text-slate-900">${sub.cost.toFixed(2)}</span>
|
||||
<span className="text-xs text-slate-500 font-medium">/{sub.billingCycle}</span>
|
||||
</div>
|
||||
{isYearly && (
|
||||
<span className="text-xs text-emerald-600 font-bold bg-emerald-50 px-2 py-0.5 rounded border border-emerald-200">
|
||||
${monthlyPrice.toFixed(2)}/mo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs pt-1 border-t border-slate-200/60">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-medium block">Payer</span>
|
||||
<span className="font-semibold text-slate-800">{payerName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-medium block">Renewal</span>
|
||||
<span className="font-semibold text-slate-800 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-slate-400" />
|
||||
{sub.renewalDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Rule Badge */}
|
||||
<div>{getSplitBadge(sub)}</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-100 text-xs">
|
||||
<button
|
||||
onClick={() => onOpenBreakdownModal(sub)}
|
||||
className="px-2.5 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium rounded-lg transition flex items-center gap-1"
|
||||
>
|
||||
<Calculator className="w-3.5 h-3.5 text-indigo-600" />
|
||||
<span>Dues Math</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => onEditSub(sub)}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition"
|
||||
title="Edit Subscription"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteSub(sub.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition"
|
||||
title="Delete Subscription"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,617 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Subscription,
|
||||
Member,
|
||||
Group,
|
||||
SplitModel,
|
||||
SplitRule,
|
||||
CustomShareType,
|
||||
BillingCycle,
|
||||
} from '../types';
|
||||
import { calculateSubscriptionMemberShares } from '../utils/splitCalculator';
|
||||
import { X, Calculator, HelpCircle, Check, Users, UserCheck, Sparkles } from 'lucide-react';
|
||||
|
||||
interface SubscriptionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (subscription: Subscription) => void;
|
||||
initialData?: Subscription | null;
|
||||
members: Member[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
export const SubscriptionModal: React.FC<SubscriptionModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialData,
|
||||
members,
|
||||
groups,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Form State
|
||||
const [name, setName] = useState(initialData?.name || '');
|
||||
const [category, setCategory] = useState<Subscription['category']>(initialData?.category || 'Streaming');
|
||||
const [cost, setCost] = useState<number>(initialData?.cost || 19.99);
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>(initialData?.billingCycle || 'monthly');
|
||||
const [payerId, setPayerId] = useState<string>(initialData?.payerId || members[0]?.id || '');
|
||||
const [renewalDate, setRenewalDate] = useState(initialData?.renewalDate || new Date().toISOString().split('T')[0]);
|
||||
const [reminderDays, setReminderDays] = useState<number>(initialData?.reminderDays || 3);
|
||||
const [notes, setNotes] = useState(initialData?.notes || '');
|
||||
|
||||
// Split Model State
|
||||
const [splitModel, setSplitModel] = useState<SplitModel>(initialData?.splitModel || 'EQUAL_ALL');
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(
|
||||
initialData?.selectedMemberIds || members.map((m) => m.id)
|
||||
);
|
||||
|
||||
// Custom Rules State
|
||||
const [customShareType, setCustomShareType] = useState<CustomShareType>(
|
||||
initialData?.customRules?.[0]?.shareType || 'percentage'
|
||||
);
|
||||
const [customRules, setCustomRules] = useState<SplitRule[]>(
|
||||
initialData?.customRules || [
|
||||
{ targetType: 'member', targetId: members[0]?.id || '', value: 50, shareType: 'percentage' },
|
||||
{ targetType: 'member', targetId: members[1]?.id || '', value: 50, shareType: 'percentage' },
|
||||
]
|
||||
);
|
||||
|
||||
// Populate/Update form when initialData changes
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setName(initialData.name);
|
||||
setCategory(initialData.category);
|
||||
setCost(initialData.cost);
|
||||
setBillingCycle(initialData.billingCycle);
|
||||
setPayerId(initialData.payerId);
|
||||
setRenewalDate(initialData.renewalDate);
|
||||
setReminderDays(initialData.reminderDays);
|
||||
setNotes(initialData.notes || '');
|
||||
setSplitModel(initialData.splitModel);
|
||||
setSelectedMemberIds(initialData.selectedMemberIds || members.map((m) => m.id));
|
||||
if (initialData.customRules) {
|
||||
setCustomRules(initialData.customRules);
|
||||
if (initialData.customRules[0]?.shareType) {
|
||||
setCustomShareType(initialData.customRules[0].shareType);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setName('');
|
||||
setCost(19.99);
|
||||
setBillingCycle('monthly');
|
||||
setPayerId(members[0]?.id || '');
|
||||
setSplitModel('EQUAL_ALL');
|
||||
setSelectedMemberIds(members.map((m) => m.id));
|
||||
}
|
||||
}, [initialData, members]);
|
||||
|
||||
// Temporary Subscription Object for Live Calculation Preview
|
||||
const previewSub: Subscription = {
|
||||
id: initialData?.id || 'preview-id',
|
||||
name: name || 'Untitled Subscription',
|
||||
category,
|
||||
cost: Number(cost) || 0,
|
||||
billingCycle,
|
||||
payerId,
|
||||
renewalDate,
|
||||
reminderDays,
|
||||
splitModel,
|
||||
selectedMemberIds,
|
||||
customRules: customRules.map((r) => ({ ...r, shareType: customShareType })),
|
||||
status: 'active',
|
||||
notes,
|
||||
};
|
||||
|
||||
const { shares, explanations } = calculateSubscriptionMemberShares(previewSub, members, groups);
|
||||
|
||||
// Toggle selected member for EQUAL_SELECTED
|
||||
const toggleMemberSelection = (id: string) => {
|
||||
if (selectedMemberIds.includes(id)) {
|
||||
setSelectedMemberIds(selectedMemberIds.filter((mId) => mId !== id));
|
||||
} else {
|
||||
setSelectedMemberIds([...selectedMemberIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
// Select all or group
|
||||
const toggleGroupSelection = (groupId: string) => {
|
||||
const groupMemberIds = members.filter((m) => m.groupId === groupId).map((m) => m.id);
|
||||
const allSelected = groupMemberIds.every((id) => selectedMemberIds.includes(id));
|
||||
|
||||
if (allSelected) {
|
||||
setSelectedMemberIds(selectedMemberIds.filter((id) => !groupMemberIds.includes(id)));
|
||||
} else {
|
||||
const merged = new Set([...selectedMemberIds, ...groupMemberIds]);
|
||||
setSelectedMemberIds(Array.from(merged));
|
||||
}
|
||||
};
|
||||
|
||||
// Custom rule manipulation
|
||||
const updateCustomRule = (index: number, field: keyof SplitRule, val: any) => {
|
||||
const updated = [...customRules];
|
||||
updated[index] = { ...updated[index], [field]: val };
|
||||
setCustomRules(updated);
|
||||
};
|
||||
|
||||
const addCustomRule = () => {
|
||||
setCustomRules([
|
||||
...customRules,
|
||||
{ targetType: 'member', targetId: members[0]?.id || '', value: 10, shareType: customShareType },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeCustomRule = (index: number) => {
|
||||
if (customRules.length <= 1) return;
|
||||
setCustomRules(customRules.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
const subToSave: Subscription = {
|
||||
...previewSub,
|
||||
id: initialData?.id || `sub-${Date.now()}`,
|
||||
};
|
||||
|
||||
onSave(subToSave);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl max-w-4xl w-full border border-slate-200 shadow-2xl overflow-hidden my-8 animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Modal Header */}
|
||||
<div className="bg-slate-900 text-white px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calculator className="w-5 h-5 text-indigo-400" />
|
||||
<h2 className="text-base font-bold">
|
||||
{initialData ? 'Edit Shared Subscription' : 'Add New Shared Subscription'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Column: Form Controls (7 cols) */}
|
||||
<div className="lg:col-span-7 space-y-5">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">1. Subscription Details</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">
|
||||
Subscription Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Netflix 4K, Spotify Family, Costco"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Category</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as any)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="Streaming">Streaming (TV/Music)</option>
|
||||
<option value="Household">Household & Grocery</option>
|
||||
<option value="Services">Services & Utilities</option>
|
||||
<option value="Gaming">Gaming & Play</option>
|
||||
<option value="Fitness">Fitness & Health</option>
|
||||
<option value="Software">Software & Cloud</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Payer (Primary Card)</label>
|
||||
<select
|
||||
value={payerId}
|
||||
onChange={(e) => setPayerId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({groups.find((g) => g.id === m.groupId)?.name || 'Individual'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Total Cost ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={cost}
|
||||
onChange={(e) => setCost(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm font-bold text-slate-900 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Billing Cycle</label>
|
||||
<select
|
||||
value={billingCycle}
|
||||
onChange={(e) => setBillingCycle(e.target.value as BillingCycle)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="monthly">Monthly ($/mo)</option>
|
||||
<option value="yearly">Yearly ($/yr)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Renewal Date</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={renewalDate}
|
||||
onChange={(e) => setRenewalDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Reminder Notice</label>
|
||||
<select
|
||||
value={reminderDays}
|
||||
onChange={(e) => setReminderDays(parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value={1}>1 day before</option>
|
||||
<option value={3}>3 days before</option>
|
||||
<option value={7}>7 days before</option>
|
||||
<option value={14}>14 days before</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Model Configuration */}
|
||||
<div className="space-y-3 pt-3 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">2. Split Arrangement Engine</h3>
|
||||
|
||||
{/* Model Tabs */}
|
||||
<div className="grid grid-cols-3 gap-2 p-1 bg-slate-100 rounded-xl text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitModel('EQUAL_ALL')}
|
||||
className={`py-2 px-2 rounded-lg transition flex flex-col items-center justify-center text-center gap-1 ${
|
||||
splitModel === 'EQUAL_ALL'
|
||||
? 'bg-white text-indigo-600 shadow-sm font-bold'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Equal (All)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitModel('EQUAL_SELECTED')}
|
||||
className={`py-2 px-2 rounded-lg transition flex flex-col items-center justify-center text-center gap-1 ${
|
||||
splitModel === 'EQUAL_SELECTED'
|
||||
? 'bg-white text-indigo-600 shadow-sm font-bold'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<UserCheck className="w-3.5 h-3.5" />
|
||||
<span>Equal (Selected)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSplitModel('CUSTOM')}
|
||||
className={`py-2 px-2 rounded-lg transition flex flex-col items-center justify-center text-center gap-1 ${
|
||||
splitModel === 'CUSTOM'
|
||||
? 'bg-white text-indigo-600 shadow-sm font-bold'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Custom Shares</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-UI for EQUAL_ALL */}
|
||||
{splitModel === 'EQUAL_ALL' && (
|
||||
<p className="text-xs text-slate-500 bg-slate-50 p-3 rounded-xl border border-slate-200">
|
||||
The total cost will be divided equally among all <strong>{members.length}</strong> registered members in the system.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sub-UI for EQUAL_SELECTED */}
|
||||
{splitModel === 'EQUAL_SELECTED' && (
|
||||
<div className="space-y-3 bg-slate-50 p-3.5 rounded-xl border border-slate-200">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-slate-700">Select Members & Family Groups:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMemberIds(members.map((m) => m.id))}
|
||||
className="text-indigo-600 hover:underline font-medium text-[11px]"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Group Toggles */}
|
||||
{groups.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-2 border-b border-slate-200">
|
||||
{groups.map((grp) => {
|
||||
const gMemberIds = members.filter((m) => m.groupId === grp.id).map((m) => m.id);
|
||||
const isFullySelected = gMemberIds.length > 0 && gMemberIds.every((id) => selectedMemberIds.includes(id));
|
||||
|
||||
return (
|
||||
<button
|
||||
key={grp.id}
|
||||
type="button"
|
||||
onClick={() => toggleGroupSelection(grp.id)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold border transition ${
|
||||
isFullySelected
|
||||
? 'bg-indigo-600 text-white border-indigo-600'
|
||||
: 'bg-white text-slate-700 border-slate-200 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
+ {grp.name} ({gMemberIds.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Pills */}
|
||||
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto pr-1">
|
||||
{members.map((m) => {
|
||||
const isSelected = selectedMemberIds.includes(m.id);
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => toggleMemberSelection(m.id)}
|
||||
className={`p-2 rounded-xl text-xs font-medium border flex items-center justify-between transition ${
|
||||
isSelected
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-900'
|
||||
: 'bg-white border-slate-200 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full ${m.avatarColor} flex items-center justify-center text-[10px] font-bold`}
|
||||
>
|
||||
{m.name[0]}
|
||||
</div>
|
||||
<span className="truncate">{m.name}</span>
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-indigo-600" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-UI for CUSTOM */}
|
||||
{splitModel === 'CUSTOM' && (
|
||||
<div className="space-y-3 bg-slate-50 p-3.5 rounded-xl border border-slate-200 text-xs">
|
||||
{/* Share Type Selector */}
|
||||
<div className="flex items-center space-x-3 text-xs mb-2">
|
||||
<span className="font-semibold text-slate-700">Split Mode:</span>
|
||||
<label className="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="shareType"
|
||||
value="percentage"
|
||||
checked={customShareType === 'percentage'}
|
||||
onChange={() => setCustomShareType('percentage')}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span>Percentage (%)</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="shareType"
|
||||
value="weight"
|
||||
checked={customShareType === 'weight'}
|
||||
onChange={() => setCustomShareType('weight')}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span>Weight Parts (e.g. 2:1:1)</span>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="shareType"
|
||||
value="fixed"
|
||||
checked={customShareType === 'fixed'}
|
||||
onChange={() => setCustomShareType('fixed')}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span>Fixed ($/mo)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="space-y-2">
|
||||
{customRules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center space-x-2 bg-white p-2 rounded-lg border border-slate-200">
|
||||
<select
|
||||
value={rule.targetType}
|
||||
onChange={(e) => updateCustomRule(idx, 'targetType', e.target.value)}
|
||||
className="bg-slate-50 border border-slate-200 rounded-md px-2 py-1 text-xs focus:outline-none"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="group">Family Group</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={rule.targetId}
|
||||
onChange={(e) => updateCustomRule(idx, 'targetId', e.target.value)}
|
||||
className="bg-slate-50 border border-slate-200 rounded-md px-2 py-1 text-xs flex-1 focus:outline-none"
|
||||
>
|
||||
{rule.targetType === 'member'
|
||||
? members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
: groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
Group: {g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="w-24 relative">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={rule.value}
|
||||
onChange={(e) => updateCustomRule(idx, 'value', parseFloat(e.target.value) || 0)}
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-md px-2 py-1 text-xs font-bold focus:outline-none"
|
||||
/>
|
||||
<span className="absolute right-2 top-1 text-[10px] text-slate-400">
|
||||
{customShareType === 'percentage' ? '%' : customShareType === 'fixed' ? '$' : 'pts'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomRule(idx)}
|
||||
className="p-1 text-slate-400 hover:text-rose-600 transition"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomRule}
|
||||
className="text-xs font-semibold text-indigo-600 hover:underline inline-block pt-1"
|
||||
>
|
||||
+ Add Target Rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-700 mb-1">Notes / Account Link</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="e.g. Account password manager link, plan details, 4K screen count..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Live Dues Calculation Preview Panel (5 cols) */}
|
||||
<div className="lg:col-span-5 bg-slate-50 rounded-2xl p-5 border border-slate-200 space-y-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-3 mb-3">
|
||||
<h3 className="font-bold text-slate-900 text-sm flex items-center gap-1.5">
|
||||
<Calculator className="w-4 h-4 text-indigo-600" />
|
||||
Live Dues Math Preview
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-emerald-600 bg-emerald-50 px-2.5 py-0.5 rounded-full border border-emerald-200">
|
||||
${(billingCycle === 'yearly' ? cost / 12 : cost).toFixed(2)}/mo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 mb-3">
|
||||
Real-time calculation of exact member dues based on selected split logic:
|
||||
</p>
|
||||
|
||||
{/* Member Dues List */}
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
|
||||
{members.map((m) => {
|
||||
const memberShare = shares[m.id] || 0;
|
||||
const explanation = explanations[m.id] || '0 share';
|
||||
const isPayer = m.id === payerId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`p-2.5 rounded-xl border text-xs transition flex items-center justify-between ${
|
||||
memberShare > 0
|
||||
? 'bg-white border-slate-200 shadow-2xs'
|
||||
: 'bg-slate-100/50 border-slate-200/60 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full ${m.avatarColor} flex items-center justify-center font-bold text-[10px]`}
|
||||
>
|
||||
{m.name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-bold text-slate-800">{m.name}</span>
|
||||
{isPayer && (
|
||||
<span className="text-[9px] bg-indigo-100 text-indigo-700 font-bold px-1.5 py-0.2 rounded">
|
||||
PAYER
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 block line-clamp-1">{explanation}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className="font-black text-slate-900 text-sm block">${memberShare.toFixed(2)}</span>
|
||||
<span className="text-[9px] text-slate-400 font-medium">/month</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-200 flex items-center justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-700 hover:bg-slate-100 rounded-xl text-xs font-semibold transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold shadow-md shadow-indigo-600/20 transition flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>{initialData ? 'Save Changes' : 'Create Subscription'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Group, Member, Subscription, SettlementRecord, OneOffExpense } from '../types';
|
||||
|
||||
export const INITIAL_GROUPS: Group[] = [
|
||||
{
|
||||
id: 'grp-smiths',
|
||||
name: 'The Smiths Family',
|
||||
description: 'Main family group for household subscriptions',
|
||||
color: 'bg-amber-500 text-amber-950 border-amber-300',
|
||||
},
|
||||
{
|
||||
id: 'grp-apt4b',
|
||||
name: 'Apartment 4B',
|
||||
description: 'Shared condo services & utilities',
|
||||
color: 'bg-indigo-500 text-indigo-950 border-indigo-300',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_MEMBERS: Member[] = [
|
||||
{
|
||||
id: 'mem-alice',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@example.com',
|
||||
groupId: 'grp-smiths',
|
||||
avatarColor: 'bg-rose-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'mem-bob',
|
||||
name: 'Bob Smith',
|
||||
email: 'bob@example.com',
|
||||
groupId: 'grp-smiths',
|
||||
avatarColor: 'bg-sky-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'mem-charlie',
|
||||
name: 'Charlie Smith',
|
||||
email: 'charlie@example.com',
|
||||
groupId: 'grp-smiths',
|
||||
avatarColor: 'bg-emerald-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'mem-david',
|
||||
name: 'David Miller',
|
||||
email: 'david@example.com',
|
||||
groupId: 'grp-apt4b',
|
||||
avatarColor: 'bg-purple-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'mem-emma',
|
||||
name: 'Emma Davis',
|
||||
email: 'emma@example.com',
|
||||
groupId: 'grp-apt4b',
|
||||
avatarColor: 'bg-amber-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'mem-frank',
|
||||
name: 'Frank Wright',
|
||||
email: 'frank@example.com',
|
||||
groupId: undefined,
|
||||
avatarColor: 'bg-teal-500 text-white',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_SUBSCRIPTIONS: Subscription[] = [
|
||||
{
|
||||
id: 'sub-netflix',
|
||||
name: 'Netflix Premium 4K',
|
||||
category: 'Streaming',
|
||||
cost: 22.99,
|
||||
billingCycle: 'monthly',
|
||||
payerId: 'mem-alice',
|
||||
renewalDate: '2026-08-15',
|
||||
reminderDays: 3,
|
||||
splitModel: 'EQUAL_ALL',
|
||||
status: 'active',
|
||||
notes: '4 simultaneous UHD streams',
|
||||
},
|
||||
{
|
||||
id: 'sub-spotify',
|
||||
name: 'Spotify Family Plan',
|
||||
category: 'Streaming',
|
||||
cost: 16.99,
|
||||
billingCycle: 'monthly',
|
||||
payerId: 'mem-bob',
|
||||
renewalDate: '2026-08-01',
|
||||
reminderDays: 2,
|
||||
splitModel: 'EQUAL_SELECTED',
|
||||
selectedMemberIds: ['mem-alice', 'mem-bob', 'mem-charlie', 'mem-david'],
|
||||
status: 'active',
|
||||
notes: '6 Premium accounts',
|
||||
},
|
||||
{
|
||||
id: 'sub-costco',
|
||||
name: 'Costco Gold Star Membership',
|
||||
category: 'Household',
|
||||
cost: 120.0,
|
||||
billingCycle: 'yearly',
|
||||
payerId: 'mem-charlie',
|
||||
renewalDate: '2026-11-20',
|
||||
reminderDays: 14,
|
||||
splitModel: 'CUSTOM',
|
||||
customRules: [
|
||||
{ targetType: 'group', targetId: 'grp-smiths', value: 70, shareType: 'percentage' },
|
||||
{ targetType: 'member', targetId: 'mem-frank', value: 30, shareType: 'percentage' },
|
||||
],
|
||||
status: 'active',
|
||||
notes: 'Includes primary card & secondary household card',
|
||||
},
|
||||
{
|
||||
id: 'sub-disney',
|
||||
name: 'Disney+ & Hulu Trio Premium',
|
||||
category: 'Streaming',
|
||||
cost: 24.99,
|
||||
billingCycle: 'monthly',
|
||||
payerId: 'mem-david',
|
||||
renewalDate: '2026-08-10',
|
||||
reminderDays: 5,
|
||||
splitModel: 'EQUAL_SELECTED',
|
||||
selectedMemberIds: ['mem-david', 'mem-emma', 'mem-alice'],
|
||||
status: 'active',
|
||||
notes: 'Includes ESPN+ ad-free',
|
||||
},
|
||||
{
|
||||
id: 'sub-icloud',
|
||||
name: 'iCloud+ 2TB Family Storage',
|
||||
category: 'Software',
|
||||
cost: 9.99,
|
||||
billingCycle: 'monthly',
|
||||
payerId: 'mem-alice',
|
||||
renewalDate: '2026-08-28',
|
||||
reminderDays: 3,
|
||||
splitModel: 'CUSTOM',
|
||||
customRules: [
|
||||
{ targetType: 'member', targetId: 'mem-alice', value: 2, shareType: 'weight' },
|
||||
{ targetType: 'member', targetId: 'mem-bob', value: 1, shareType: 'weight' },
|
||||
{ targetType: 'member', targetId: 'mem-charlie', value: 1, shareType: 'weight' },
|
||||
],
|
||||
status: 'active',
|
||||
notes: 'Alice uses 1TB, Bob & Charlie 500GB each',
|
||||
},
|
||||
{
|
||||
id: 'sub-nintendo',
|
||||
name: 'Nintendo Switch Online Family',
|
||||
category: 'Gaming',
|
||||
cost: 79.99,
|
||||
billingCycle: 'yearly',
|
||||
payerId: 'mem-frank',
|
||||
renewalDate: '2026-09-05',
|
||||
reminderDays: 7,
|
||||
splitModel: 'CUSTOM',
|
||||
customRules: [
|
||||
{ targetType: 'member', targetId: 'mem-frank', value: 2.0, shareType: 'fixed' },
|
||||
{ targetType: 'member', targetId: 'mem-david', value: 2.0, shareType: 'fixed' },
|
||||
{ targetType: 'member', targetId: 'mem-emma', value: 2.67, shareType: 'fixed' },
|
||||
],
|
||||
status: 'active',
|
||||
notes: 'Includes Expansion Pack with N64 games',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_SETTLEMENTS: SettlementRecord[] = [
|
||||
{
|
||||
id: 'settle-1',
|
||||
fromMemberId: 'mem-bob',
|
||||
toMemberId: 'mem-alice',
|
||||
amount: 15.0,
|
||||
date: '2026-07-15',
|
||||
note: 'Partial payment for July Netflix & Spotify adjustment',
|
||||
},
|
||||
];
|
||||
|
||||
export const INITIAL_ONE_OFF_EXPENSES: OneOffExpense[] = [
|
||||
{
|
||||
id: 'exp-dinner-1',
|
||||
title: 'Group Dinner at Trattoria Mario',
|
||||
amount: 145.50,
|
||||
date: '2026-07-28',
|
||||
payerId: 'mem-bob',
|
||||
category: 'Dining',
|
||||
splitModel: 'EQUAL_ALL',
|
||||
notes: 'Dinner after weekend family gathering. Includes tax & tip.',
|
||||
},
|
||||
{
|
||||
id: 'exp-groceries-1',
|
||||
title: 'Lake Tahoe Trip Grocery Run',
|
||||
amount: 210.00,
|
||||
date: '2026-07-20',
|
||||
payerId: 'mem-alice',
|
||||
category: 'Groceries',
|
||||
splitModel: 'EQUAL_SELECTED',
|
||||
selectedMemberIds: ['mem-alice', 'mem-bob', 'mem-charlie', 'mem-frank'],
|
||||
notes: 'Snacks, drinks, and barbecue items for the cabin weekend',
|
||||
},
|
||||
{
|
||||
id: 'exp-concert-1',
|
||||
title: 'IndieFest Summer Concert Tickets',
|
||||
amount: 180.00,
|
||||
date: '2026-07-12',
|
||||
payerId: 'mem-david',
|
||||
category: 'Entertainment',
|
||||
splitModel: 'EQUAL_SELECTED',
|
||||
selectedMemberIds: ['mem-david', 'mem-emma'],
|
||||
notes: '2 Front section VIP lawn passes',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for crisp professional look */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.6);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Core Data Models for Shared Subscription & Multi-Family Split Tracker
|
||||
*/
|
||||
|
||||
export type BillingCycle = 'monthly' | 'yearly';
|
||||
|
||||
export type SplitModel = 'EQUAL_ALL' | 'EQUAL_SELECTED' | 'CUSTOM';
|
||||
|
||||
export type CustomShareType = 'percentage' | 'fixed' | 'weight';
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color: string; // Tailwind color class or hex string
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
groupId?: string; // Member belongs to a family/group
|
||||
avatarColor: string;
|
||||
}
|
||||
|
||||
export interface SplitRule {
|
||||
targetType: 'member' | 'group';
|
||||
targetId: string; // memberId or groupId
|
||||
value: number; // percentage (e.g. 50 = 50%), weight (e.g. 2 = 2 parts), or fixed monthly amount ($)
|
||||
shareType?: CustomShareType;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'Streaming' | 'Household' | 'Services' | 'Gaming' | 'Fitness' | 'Software' | 'Utilities' | 'Other';
|
||||
cost: number; // Amount charged per cycle
|
||||
billingCycle: BillingCycle;
|
||||
payerId: string; // Member ID who pays the bill
|
||||
renewalDate: string; // YYYY-MM-DD
|
||||
reminderDays: number; // Days before renewal to remind
|
||||
splitModel: SplitModel;
|
||||
// Selected member IDs if splitModel === 'EQUAL_SELECTED'
|
||||
selectedMemberIds?: string[];
|
||||
// Custom split rules if splitModel === 'CUSTOM'
|
||||
customRules?: SplitRule[];
|
||||
status: 'active' | 'paused';
|
||||
notes?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface OneOffExpense {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
payerId: string; // Member who paid for the expense
|
||||
category?: 'Dining' | 'Travel' | 'Entertainment' | 'Groceries' | 'Events' | 'Utilities' | 'Other';
|
||||
splitModel: SplitModel;
|
||||
selectedMemberIds?: string[];
|
||||
customRules?: SplitRule[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SettlementRecord {
|
||||
id: string;
|
||||
fromMemberId: string;
|
||||
toMemberId: string;
|
||||
amount: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface MemberDueBreakdown {
|
||||
subscriptionId: string;
|
||||
subscriptionName: string;
|
||||
payerId: string;
|
||||
totalCostMonthly: number;
|
||||
myMonthlyShare: number;
|
||||
splitModel: SplitModel;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface MemberExpenseBreakdown {
|
||||
expenseId: string;
|
||||
expenseTitle: string;
|
||||
payerId: string;
|
||||
totalCost: number;
|
||||
myShare: number;
|
||||
splitModel: SplitModel;
|
||||
date: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface MemberBalance {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
groupId?: string;
|
||||
avatarColor: string;
|
||||
totalPaidUpfrontMonthly: number; // What they pay directly for subscriptions per month
|
||||
totalOwedMonthly: number; // What their fair share is per month across all subs
|
||||
totalPaidOneOff: number; // Paid upfront for one-off charges
|
||||
totalOwedOneOff: number; // Owed share for one-off charges
|
||||
netMonthlyBalance: number; // totalPaidUpfrontMonthly - totalOwedMonthly + (totalPaidOneOff - totalOwedOneOff) + settlementAdj
|
||||
dueBreakdown: MemberDueBreakdown[];
|
||||
expenseBreakdown: MemberExpenseBreakdown[];
|
||||
}
|
||||
|
||||
export interface DebtTransaction {
|
||||
fromMemberId: string;
|
||||
fromMemberName: string;
|
||||
toMemberId: string;
|
||||
toMemberName: string;
|
||||
amount: number;
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import {
|
||||
Subscription,
|
||||
OneOffExpense,
|
||||
Member,
|
||||
Group,
|
||||
SplitRule,
|
||||
MemberBalance,
|
||||
DebtTransaction,
|
||||
SettlementRecord,
|
||||
MemberDueBreakdown,
|
||||
MemberExpenseBreakdown,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Normalizes subscription cost to a monthly figure
|
||||
*/
|
||||
export function getMonthlyCost(cost: number, billingCycle: 'monthly' | 'yearly'): number {
|
||||
if (isNaN(cost) || cost <= 0) return 0;
|
||||
return billingCycle === 'yearly' ? cost / 12 : cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates per-member monthly shares for a single subscription
|
||||
*/
|
||||
export function calculateSubscriptionMemberShares(
|
||||
sub: Subscription,
|
||||
allMembers: Member[],
|
||||
allGroups: Group[]
|
||||
): { shares: Record<string, number>; explanations: Record<string, string> } {
|
||||
const shares: Record<string, number> = {};
|
||||
const explanations: Record<string, string> = {};
|
||||
|
||||
// Initialize 0 share for all active members
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = 0;
|
||||
explanations[m.id] = 'No share assigned';
|
||||
});
|
||||
|
||||
if (allMembers.length === 0 || sub.status === 'paused') {
|
||||
return { shares, explanations };
|
||||
}
|
||||
|
||||
const monthlyTotal = getMonthlyCost(sub.cost, sub.billingCycle);
|
||||
|
||||
// Helper: Get member IDs belonging to a group
|
||||
const getGroupMemberIds = (groupId: string): string[] => {
|
||||
return allMembers.filter((m) => m.groupId === groupId).map((m) => m.id);
|
||||
};
|
||||
|
||||
switch (sub.splitModel) {
|
||||
case 'EQUAL_ALL': {
|
||||
const perPerson = monthlyTotal / allMembers.length;
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = perPerson;
|
||||
explanations[m.id] = `Equal split among all ${allMembers.length} members ($${monthlyTotal.toFixed(2)} / ${allMembers.length})`;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'EQUAL_SELECTED': {
|
||||
let targetMemberIds = new Set<string>();
|
||||
(sub.selectedMemberIds || []).forEach((id) => {
|
||||
// Check if id is a member or a group
|
||||
const isMember = allMembers.some((m) => m.id === id);
|
||||
if (isMember) {
|
||||
targetMemberIds.add(id);
|
||||
} else {
|
||||
// It might be a group ID
|
||||
getGroupMemberIds(id).forEach((gmId) => targetMemberIds.add(gmId));
|
||||
}
|
||||
});
|
||||
|
||||
const memberList = Array.from(targetMemberIds);
|
||||
if (memberList.length === 0) {
|
||||
// Fallback to equal all if no valid selection
|
||||
const perPerson = monthlyTotal / allMembers.length;
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = perPerson;
|
||||
explanations[m.id] = `Fallback equal split (no members selected)`;
|
||||
});
|
||||
} else {
|
||||
const perPerson = monthlyTotal / memberList.length;
|
||||
memberList.forEach((mId) => {
|
||||
shares[mId] = perPerson;
|
||||
explanations[mId] = `Equal split among ${memberList.length} selected members ($${monthlyTotal.toFixed(2)} / ${memberList.length})`;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CUSTOM': {
|
||||
const rules = sub.customRules || [];
|
||||
if (rules.length === 0) {
|
||||
// Fallback to equal all
|
||||
const perPerson = monthlyTotal / allMembers.length;
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = perPerson;
|
||||
explanations[m.id] = `Fallback equal split (no custom rules defined)`;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Check share type (percentage, fixed, or weight)
|
||||
// Determine dominant share type
|
||||
const firstType = rules[0]?.shareType || 'percentage';
|
||||
|
||||
if (firstType === 'fixed') {
|
||||
// Fixed dollar amount assigned per rule
|
||||
rules.forEach((rule) => {
|
||||
if (rule.targetType === 'member') {
|
||||
shares[rule.targetId] = (shares[rule.targetId] || 0) + rule.value;
|
||||
explanations[rule.targetId] = `Fixed amount of $${rule.value.toFixed(2)}/mo`;
|
||||
} else if (rule.targetType === 'group') {
|
||||
const gMemberIds = getGroupMemberIds(rule.targetId);
|
||||
if (gMemberIds.length > 0) {
|
||||
const perGMember = rule.value / gMemberIds.length;
|
||||
gMemberIds.forEach((gmId) => {
|
||||
shares[gmId] = (shares[gmId] || 0) + perGMember;
|
||||
explanations[gmId] = `Group fixed share of $${rule.value.toFixed(2)} divided among ${gMemberIds.length} family members`;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (firstType === 'weight') {
|
||||
// Weights / parts (e.g., Person A = 2, Person B = 1)
|
||||
let totalWeight = 0;
|
||||
const expandedWeights: Record<string, { weight: number; desc: string }> = {};
|
||||
|
||||
rules.forEach((rule) => {
|
||||
if (rule.targetType === 'member') {
|
||||
expandedWeights[rule.targetId] = {
|
||||
weight: (expandedWeights[rule.targetId]?.weight || 0) + rule.value,
|
||||
desc: `Weight of ${rule.value} parts`,
|
||||
};
|
||||
totalWeight += rule.value;
|
||||
} else if (rule.targetType === 'group') {
|
||||
const gMemberIds = getGroupMemberIds(rule.targetId);
|
||||
if (gMemberIds.length > 0) {
|
||||
const weightPerGMember = rule.value / gMemberIds.length;
|
||||
gMemberIds.forEach((gmId) => {
|
||||
expandedWeights[gmId] = {
|
||||
weight: (expandedWeights[gmId]?.weight || 0) + weightPerGMember,
|
||||
desc: `Group weight of ${rule.value} parts split across family`,
|
||||
};
|
||||
});
|
||||
totalWeight += rule.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (totalWeight > 0) {
|
||||
Object.entries(expandedWeights).forEach(([mId, item]) => {
|
||||
const fraction = item.weight / totalWeight;
|
||||
shares[mId] = monthlyTotal * fraction;
|
||||
explanations[mId] = `${item.desc} (${(fraction * 100).toFixed(1)}% of total $${monthlyTotal.toFixed(2)})`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Percentage split (e.g. 60% / 40%)
|
||||
let totalPercent = 0;
|
||||
rules.forEach((r) => (totalPercent += r.value));
|
||||
|
||||
// Normalize percentages if they don't sum to 100
|
||||
const scale = totalPercent > 0 ? 100 / totalPercent : 1;
|
||||
|
||||
rules.forEach((rule) => {
|
||||
const effectivePct = rule.value * scale;
|
||||
const ruleCost = (monthlyTotal * effectivePct) / 100;
|
||||
|
||||
if (rule.targetType === 'member') {
|
||||
shares[rule.targetId] = (shares[rule.targetId] || 0) + ruleCost;
|
||||
explanations[rule.targetId] = `${effectivePct.toFixed(1)}% share ($${ruleCost.toFixed(2)}/mo)`;
|
||||
} else if (rule.targetType === 'group') {
|
||||
const gMemberIds = getGroupMemberIds(rule.targetId);
|
||||
if (gMemberIds.length > 0) {
|
||||
const perGMember = ruleCost / gMemberIds.length;
|
||||
gMemberIds.forEach((gmId) => {
|
||||
shares[gmId] = (shares[gmId] || 0) + perGMember;
|
||||
explanations[gmId] = `Family ${effectivePct.toFixed(1)}% share ($${ruleCost.toFixed(2)}/mo) split across ${gMemberIds.length} members`;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { shares, explanations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates per-member shares for a single one-off expense
|
||||
*/
|
||||
export function calculateExpenseMemberShares(
|
||||
expense: OneOffExpense,
|
||||
allMembers: Member[],
|
||||
allGroups: Group[]
|
||||
): { shares: Record<string, number>; explanations: Record<string, string> } {
|
||||
const shares: Record<string, number> = {};
|
||||
const explanations: Record<string, string> = {};
|
||||
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = 0;
|
||||
explanations[m.id] = 'No share assigned';
|
||||
});
|
||||
|
||||
if (allMembers.length === 0 || expense.amount <= 0) {
|
||||
return { shares, explanations };
|
||||
}
|
||||
|
||||
const total = expense.amount;
|
||||
|
||||
const getGroupMemberIds = (groupId: string): string[] => {
|
||||
return allMembers.filter((m) => m.groupId === groupId).map((m) => m.id);
|
||||
};
|
||||
|
||||
switch (expense.splitModel) {
|
||||
case 'EQUAL_ALL': {
|
||||
const perPerson = total / allMembers.length;
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = perPerson;
|
||||
explanations[m.id] = `Equal split among all ${allMembers.length} members ($${total.toFixed(2)} / ${allMembers.length})`;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'EQUAL_SELECTED': {
|
||||
let targetMemberIds = new Set<string>();
|
||||
(expense.selectedMemberIds || []).forEach((id) => {
|
||||
const isMember = allMembers.some((m) => m.id === id);
|
||||
if (isMember) {
|
||||
targetMemberIds.add(id);
|
||||
} else {
|
||||
getGroupMemberIds(id).forEach((gmId) => targetMemberIds.add(gmId));
|
||||
}
|
||||
});
|
||||
|
||||
const memberList = Array.from(targetMemberIds);
|
||||
if (memberList.length === 0) {
|
||||
const perPerson = total / allMembers.length;
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = perPerson;
|
||||
explanations[m.id] = `Fallback equal split (no members selected)`;
|
||||
});
|
||||
} else {
|
||||
const perPerson = total / memberList.length;
|
||||
memberList.forEach((mId) => {
|
||||
shares[mId] = perPerson;
|
||||
explanations[mId] = `Equal split among ${memberList.length} attending/selected members ($${total.toFixed(2)} / ${memberList.length})`;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CUSTOM': {
|
||||
const rules = expense.customRules || [];
|
||||
if (rules.length === 0) {
|
||||
const perPerson = total / allMembers.length;
|
||||
allMembers.forEach((m) => {
|
||||
shares[m.id] = perPerson;
|
||||
explanations[m.id] = `Fallback equal split (no custom rules defined)`;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const firstType = rules[0]?.shareType || 'percentage';
|
||||
|
||||
if (firstType === 'fixed') {
|
||||
rules.forEach((rule) => {
|
||||
if (rule.targetType === 'member') {
|
||||
shares[rule.targetId] = (shares[rule.targetId] || 0) + rule.value;
|
||||
explanations[rule.targetId] = `Fixed contribution of $${rule.value.toFixed(2)}`;
|
||||
} else if (rule.targetType === 'group') {
|
||||
const gMemberIds = getGroupMemberIds(rule.targetId);
|
||||
if (gMemberIds.length > 0) {
|
||||
const perGMember = rule.value / gMemberIds.length;
|
||||
gMemberIds.forEach((gmId) => {
|
||||
shares[gmId] = (shares[gmId] || 0) + perGMember;
|
||||
explanations[gmId] = `Group fixed share of $${rule.value.toFixed(2)} split across family members`;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (firstType === 'weight') {
|
||||
let totalWeight = 0;
|
||||
const expandedWeights: Record<string, { weight: number; desc: string }> = {};
|
||||
|
||||
rules.forEach((rule) => {
|
||||
if (rule.targetType === 'member') {
|
||||
expandedWeights[rule.targetId] = {
|
||||
weight: (expandedWeights[rule.targetId]?.weight || 0) + rule.value,
|
||||
desc: `Weight of ${rule.value} parts`,
|
||||
};
|
||||
totalWeight += rule.value;
|
||||
} else if (rule.targetType === 'group') {
|
||||
const gMemberIds = getGroupMemberIds(rule.targetId);
|
||||
if (gMemberIds.length > 0) {
|
||||
const weightPerGMember = rule.value / gMemberIds.length;
|
||||
gMemberIds.forEach((gmId) => {
|
||||
expandedWeights[gmId] = {
|
||||
weight: (expandedWeights[gmId]?.weight || 0) + weightPerGMember,
|
||||
desc: `Group weight of ${rule.value} parts split across family`,
|
||||
};
|
||||
});
|
||||
totalWeight += rule.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (totalWeight > 0) {
|
||||
Object.entries(expandedWeights).forEach(([mId, item]) => {
|
||||
const fraction = item.weight / totalWeight;
|
||||
shares[mId] = total * fraction;
|
||||
explanations[mId] = `${item.desc} (${(fraction * 100).toFixed(1)}% of $${total.toFixed(2)})`;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let totalPercent = 0;
|
||||
rules.forEach((r) => (totalPercent += r.value));
|
||||
const scale = totalPercent > 0 ? 100 / totalPercent : 1;
|
||||
|
||||
rules.forEach((rule) => {
|
||||
const effectivePct = rule.value * scale;
|
||||
const ruleCost = (total * effectivePct) / 100;
|
||||
|
||||
if (rule.targetType === 'member') {
|
||||
shares[rule.targetId] = (shares[rule.targetId] || 0) + ruleCost;
|
||||
explanations[rule.targetId] = `${effectivePct.toFixed(1)}% share ($${ruleCost.toFixed(2)})`;
|
||||
} else if (rule.targetType === 'group') {
|
||||
const gMemberIds = getGroupMemberIds(rule.targetId);
|
||||
if (gMemberIds.length > 0) {
|
||||
const perGMember = ruleCost / gMemberIds.length;
|
||||
gMemberIds.forEach((gmId) => {
|
||||
shares[gmId] = (shares[gmId] || 0) + perGMember;
|
||||
explanations[gmId] = `Family ${effectivePct.toFixed(1)}% share ($${ruleCost.toFixed(2)}) split across ${gMemberIds.length} members`;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { shares, explanations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates net balances for all members across all active subscriptions AND one-off charges,
|
||||
* factoring in payer upfront outlays and past settlement payments.
|
||||
*/
|
||||
export function calculateAllMemberBalances(
|
||||
subscriptions: Subscription[],
|
||||
oneOffExpenses: OneOffExpense[] = [],
|
||||
members: Member[],
|
||||
groups: Group[],
|
||||
settlements: SettlementRecord[] = []
|
||||
): MemberBalance[] {
|
||||
const memberMap = new Map<string, MemberBalance>();
|
||||
|
||||
// Initialize balance struct for each member
|
||||
members.forEach((m) => {
|
||||
memberMap.set(m.id, {
|
||||
memberId: m.id,
|
||||
memberName: m.name,
|
||||
groupId: m.groupId,
|
||||
avatarColor: m.avatarColor,
|
||||
totalPaidUpfrontMonthly: 0,
|
||||
totalOwedMonthly: 0,
|
||||
totalPaidOneOff: 0,
|
||||
totalOwedOneOff: 0,
|
||||
netMonthlyBalance: 0,
|
||||
dueBreakdown: [],
|
||||
expenseBreakdown: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate subscription shares & payer outlays
|
||||
subscriptions
|
||||
.filter((s) => s.status === 'active')
|
||||
.forEach((sub) => {
|
||||
const monthlyTotal = getMonthlyCost(sub.cost, sub.billingCycle);
|
||||
|
||||
// Payer gets credited for upfront outlay
|
||||
const payerBalance = memberMap.get(sub.payerId);
|
||||
if (payerBalance) {
|
||||
payerBalance.totalPaidUpfrontMonthly += monthlyTotal;
|
||||
}
|
||||
|
||||
// Calculate member shares
|
||||
const { shares, explanations } = calculateSubscriptionMemberShares(sub, members, groups);
|
||||
|
||||
Object.entries(shares).forEach(([memberId, shareAmount]) => {
|
||||
const mBalance = memberMap.get(memberId);
|
||||
if (mBalance) {
|
||||
mBalance.totalOwedMonthly += shareAmount;
|
||||
if (shareAmount > 0) {
|
||||
mBalance.dueBreakdown.push({
|
||||
subscriptionId: sub.id,
|
||||
subscriptionName: sub.name,
|
||||
payerId: sub.payerId,
|
||||
totalCostMonthly: monthlyTotal,
|
||||
myMonthlyShare: shareAmount,
|
||||
splitModel: sub.splitModel,
|
||||
explanation: explanations[memberId] || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate one-off expense shares & payer outlays
|
||||
oneOffExpenses.forEach((exp) => {
|
||||
const totalExp = exp.amount;
|
||||
|
||||
// Payer gets credited for paying the charge upfront
|
||||
const payerBalance = memberMap.get(exp.payerId);
|
||||
if (payerBalance) {
|
||||
payerBalance.totalPaidOneOff += totalExp;
|
||||
}
|
||||
|
||||
const { shares, explanations } = calculateExpenseMemberShares(exp, members, groups);
|
||||
|
||||
Object.entries(shares).forEach(([memberId, shareAmount]) => {
|
||||
const mBalance = memberMap.get(memberId);
|
||||
if (mBalance) {
|
||||
mBalance.totalOwedOneOff += shareAmount;
|
||||
if (shareAmount > 0) {
|
||||
mBalance.expenseBreakdown.push({
|
||||
expenseId: exp.id,
|
||||
expenseTitle: exp.title,
|
||||
payerId: exp.payerId,
|
||||
totalCost: totalExp,
|
||||
myShare: shareAmount,
|
||||
splitModel: exp.splitModel,
|
||||
date: exp.date,
|
||||
explanation: explanations[memberId] || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Apply settlements adjustment to net balance
|
||||
const settlementOffsets: Record<string, number> = {};
|
||||
members.forEach((m) => (settlementOffsets[m.id] = 0));
|
||||
|
||||
settlements.forEach((s) => {
|
||||
settlementOffsets[s.fromMemberId] = (settlementOffsets[s.fromMemberId] || 0) + s.amount;
|
||||
settlementOffsets[s.toMemberId] = (settlementOffsets[s.toMemberId] || 0) - s.amount;
|
||||
});
|
||||
|
||||
// Compute final net balance = (Total Sub Paid + Total OneOff Paid) - (Total Sub Owed + Total OneOff Owed) + Settlement Adjustments
|
||||
const result: MemberBalance[] = [];
|
||||
memberMap.forEach((bal) => {
|
||||
const settlementAdj = settlementOffsets[bal.memberId] || 0;
|
||||
const netSub = bal.totalPaidUpfrontMonthly - bal.totalOwedMonthly;
|
||||
const netOneOff = bal.totalPaidOneOff - bal.totalOwedOneOff;
|
||||
bal.netMonthlyBalance = netSub + netOneOff + settlementAdj;
|
||||
result.push(bal);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedily resolves net balances into minimal "Who Owes Whom" transactions.
|
||||
*/
|
||||
export function calculateSimplifiedDebts(
|
||||
balances: MemberBalance[]
|
||||
): DebtTransaction[] {
|
||||
// Debtors have negative net balance (they owe money)
|
||||
// Creditors have positive net balance (they are owed money)
|
||||
const debtors: { id: string; name: string; amount: number }[] = [];
|
||||
const creditors: { id: string; name: string; amount: number }[] = [];
|
||||
|
||||
balances.forEach((b) => {
|
||||
// Round to 2 decimal places to prevent floating point residue
|
||||
const net = Math.round(b.netMonthlyBalance * 100) / 100;
|
||||
if (net < -0.01) {
|
||||
debtors.push({ id: b.memberId, name: b.memberName, amount: -net });
|
||||
} else if (net > 0.01) {
|
||||
creditors.push({ id: b.memberId, name: b.memberName, amount: net });
|
||||
}
|
||||
});
|
||||
|
||||
const transactions: DebtTransaction[] = [];
|
||||
|
||||
let dIdx = 0;
|
||||
let cIdx = 0;
|
||||
|
||||
while (dIdx < debtors.length && cIdx < creditors.length) {
|
||||
const debtor = debtors[dIdx];
|
||||
const creditor = creditors[cIdx];
|
||||
|
||||
const payment = Math.min(debtor.amount, creditor.amount);
|
||||
const roundedPayment = Math.round(payment * 100) / 100;
|
||||
|
||||
if (roundedPayment > 0) {
|
||||
transactions.push({
|
||||
fromMemberId: debtor.id,
|
||||
fromMemberName: debtor.name,
|
||||
toMemberId: creditor.id,
|
||||
toMemberName: creditor.name,
|
||||
amount: roundedPayment,
|
||||
});
|
||||
}
|
||||
|
||||
debtor.amount -= payment;
|
||||
creditor.amount -= payment;
|
||||
|
||||
if (debtor.amount <= 0.01) dIdx++;
|
||||
if (creditor.amount <= 0.01) cIdx++;
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user