381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|