Initial commit: SubSplit - subscription split tracker

This commit is contained in:
2026-07-31 13:40:26 -07:00
commit 30eb44429c
29 changed files with 9081 additions and 0 deletions
+106
View File
@@ -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>
);
};
+481
View File
@@ -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>
);
};
+374
View File
@@ -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>
);
};
+197
View File
@@ -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>
);
};
+205
View File
@@ -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>
);
};
+243
View File
@@ -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>
);
};
+306
View File
@@ -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>
);
};
+581
View File
@@ -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>
);
};
+88
View File
@@ -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>
);
};
+167
View File
@@ -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>
);
};
+119
View File
@@ -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>
);
};
+270
View File
@@ -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>
);
};
+617
View File
@@ -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>
);
};