Initial commit: SubSplit - subscription split tracker
This commit is contained in:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user