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