diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index ddd88b8..885905b 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -9,6 +9,7 @@ import { groupRoutes } from './routes/groups.js'; import { memberRoutes } from './routes/members.js'; import { expenseRoutes } from './routes/expenses.js'; import { balanceRoutes } from './routes/balances.js'; +import { settleRoutes } from './routes/settle.js'; import { authRoutes } from './routes/auth.js'; import { userRoutes } from './routes/user.js'; @@ -62,6 +63,7 @@ export async function buildApp() { await fastify.register(memberRoutes); await fastify.register(expenseRoutes); await fastify.register(balanceRoutes); + await fastify.register(settleRoutes); fastify.get('/health', async () => ({ status: 'ok' })); diff --git a/packages/api/src/routes/groups.ts b/packages/api/src/routes/groups.ts index 8e35e24..65c841e 100644 --- a/packages/api/src/routes/groups.ts +++ b/packages/api/src/routes/groups.ts @@ -186,6 +186,28 @@ export async function groupRoutes(fastify: FastifyInstance) { }, ); + fastify.delete( + '/api/v1/groups/:id', + { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + tags: ['groups'], + summary: 'Delete a group and all its data (owner only)', + }, + preHandler: [requireSession, requireGroupMember, requireOwner], + }, + async (request, reply) => { + const { id } = request.params as { id: string }; + // CASCADE on foreign keys cleans up members, expenses, splits, activity logs + await db.delete(groups).where(eq(groups.id, id)); + return reply.status(204).send(); + }, + ); + fastify.get( '/api/v1/groups/:id/activity', { diff --git a/packages/api/src/routes/members.ts b/packages/api/src/routes/members.ts index fb96eed..9a856dd 100644 --- a/packages/api/src/routes/members.ts +++ b/packages/api/src/routes/members.ts @@ -217,4 +217,37 @@ export async function memberRoutes(fastify: FastifyInstance) { return reply.status(204).send(); }, ); + + fastify.post( + '/api/v1/groups/:id/leave', + { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + tags: ['members'], + summary: 'Leave a group (non-owner members only)', + }, + preHandler: [requireSession, requireGroupMember], + }, + async (request, reply) => { + const { id } = request.params as { id: string }; + const member = request.member!; + + if (member.role === 'owner') { + return reply.status(403).send({ error: 'Group owner cannot leave. Delete the group instead.' }); + } + + await db.update(members).set({ leftAt: new Date() }).where(eq(members.id, member.id)); + + await db.insert(activityLog).values({ + groupId: id, + message: `${member.displayName} left the group`, + }); + + return reply.status(204).send(); + }, + ); } diff --git a/packages/api/src/routes/settle.ts b/packages/api/src/routes/settle.ts new file mode 100644 index 0000000..50883a1 --- /dev/null +++ b/packages/api/src/routes/settle.ts @@ -0,0 +1,86 @@ +import { FastifyInstance } from 'fastify'; +import { randomUUID } from 'crypto'; +import { db, members, expenses, expenseSplits, groups, activityLog } from '../db/index.js'; +import { eq, and, isNull } from 'drizzle-orm'; +import { requireSession, requireGroupMember } from '../plugins/session.js'; +import { groupExpiresAt } from '../lib/time.js'; + +export async function settleRoutes(fastify: FastifyInstance) { + fastify.post( + '/api/v1/groups/:id/settle', + { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['from', 'to', 'amountCents'], + properties: { + from: { type: 'string' }, + to: { type: 'string' }, + amountCents: { type: 'integer', minimum: 1 }, + }, + }, + tags: ['balances'], + summary: 'Record a settlement payment between two members', + }, + preHandler: [requireSession, requireGroupMember], + }, + async (request, reply) => { + const { id } = request.params as { id: string }; + const { from, to, amountCents } = request.body as { + from: string; + to: string; + amountCents: number; + }; + + const [group] = await db.select().from(groups).where(eq(groups.id, id)); + if (!group) return reply.status(404).send({ error: 'Group not found' }); + if (group.expiresAt < new Date()) { + return reply.status(410).send({ error: 'Group has expired' }); + } + + const memberRows = await db + .select({ id: members.id, displayName: members.displayName }) + .from(members) + .where(and(eq(members.groupId, id), isNull(members.leftAt))); + + const memberMap = new Map(memberRows.map((m) => [m.id, m.displayName])); + if (!memberMap.has(from) || !memberMap.has(to)) { + return reply.status(400).send({ error: 'Invalid member IDs' }); + } + + // Record the settlement as an exact-split expense so the ledger stays + // consistent: paidBy=from credits `from`, split on `to` debits `to`. + const amount = String(amountCents / 100); + const expenseId = randomUUID(); + + await db.insert(expenses).values({ + id: expenseId, + groupId: id, + paidBy: from, + amount, + description: 'πŸ’Έ Settlement', + splitType: 'exact', + }); + + await db.insert(expenseSplits).values({ + expenseId, + memberId: to, + amount, + }); + + await db.insert(activityLog).values({ + groupId: id, + message: `${memberMap.get(from)} settled $${(amountCents / 100).toFixed(2)} with ${memberMap.get(to)}`, + }); + + await db.update(groups).set({ expiresAt: groupExpiresAt() }).where(eq(groups.id, id)); + + return reply.status(204).send(); + }, + ); +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 9acea18..b3d4fee 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -9,6 +9,8 @@ import CreateGroupPage from './pages/CreateGroupPage.js'; import JoinPage from './pages/JoinPage.js'; import DashboardPage from './pages/DashboardPage.js'; import SettingsPage from './pages/SettingsPage.js'; +import GroupSettingsPage from './pages/GroupSettingsPage.js'; +import AccountPage from './pages/AccountPage.js'; export default function App() { return ( @@ -21,6 +23,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/packages/web/src/components/AddExpenseModal.tsx b/packages/web/src/components/AddExpenseModal.tsx index da4b018..7978060 100644 --- a/packages/web/src/components/AddExpenseModal.tsx +++ b/packages/web/src/components/AddExpenseModal.tsx @@ -1,8 +1,6 @@ -import { useState, FormEvent } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { Member, Expense, SplitType, ExactSplitInput, PercentageSplitInput } from '@tabby/shared'; -import { Modal } from './Modal.js'; -import { Button } from './Button.js'; -import { Input } from './Input.js'; +import { Avatar } from './Avatar.js'; interface AddExpenseModalProps { open: boolean; @@ -19,6 +17,80 @@ interface AddExpenseModalProps { }) => Promise; } +// ─── Icons ──────────────────────────────────────────────────────────────────── + +function CheckIcon({ size = 14 }: { size?: number }) { + return ( + + + + ); +} + +function PencilIcon() { + return ( + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} + +// ─── Step header ───────────────────────────────────────────────────────────── + +interface StepHeaderProps { + idx: number; + label: string; + summary: string | null; + expanded: boolean; + done: boolean; + disabled: boolean; + onClick: () => void; +} + +function StepHeader({ idx, label, summary, expanded, done, disabled, onClick }: StepHeaderProps) { + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + export function AddExpenseModal({ open, onClose, @@ -28,10 +100,13 @@ export function AddExpenseModal({ onSave, }: AddExpenseModalProps) { const isEditing = !!initialExpense; - const [description, setDescription] = useState(initialExpense?.description ?? ''); + + const [step, setStep] = useState<1 | 2 | 3>(1); const [amount, setAmount] = useState(initialExpense ? String(Number(initialExpense.amount)) : ''); + const [description, setDescription] = useState(initialExpense?.description ?? ''); + const [paidBy, setPaidBy] = useState(initialExpense?.paidBy ?? currentMemberId); const [splitType, setSplitType] = useState(initialExpense?.splitType ?? 'equal'); - const [selectedIds, setSelectedIds] = useState( + const [included, setIncluded] = useState( initialExpense ? initialExpense.splits.map((s) => s.memberId) : members.map((m) => m.id), ); const [exactAmounts, setExactAmounts] = useState>( @@ -49,37 +124,96 @@ export function AddExpenseModal({ ) : {}, ); - const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const amountInputRef = useRef(null); - const toggleMember = (id: string) => { - setSelectedIds((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (open) { + document.addEventListener('keydown', handler); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handler); + document.body.style.overflow = ''; + }; + }, [open, onClose]); + + useEffect(() => { + if (open && step === 1) { + setTimeout(() => amountInputRef.current?.focus(), 50); + } + }, [open, step]); + + if (!open) return null; + + // ─ Derived state ───────────────────────────────────────────────────────── + + const amountNum = parseFloat(amount) || 0; + const amountCents = Math.round(amountNum * 100); + + let assignedCents = 0; + if (splitType === 'exact') { + assignedCents = included.reduce( + (sum, id) => sum + Math.round((parseFloat(exactAmounts[id] ?? '0') || 0) * 100), + 0, ); + } else if (splitType === 'percentage') { + const totalPct = included.reduce((sum, id) => sum + (parseFloat(percentages[id] ?? '0') || 0), 0); + assignedCents = Math.round((totalPct / 100) * amountCents); + } else { + assignedCents = amountCents; + } + const remainderCents = amountCents - assignedCents; + const equalShareCents = included.length > 0 ? Math.floor(amountCents / included.length) : 0; + + const step1Done = amountNum > 0 && description.trim().length > 0; + const step2Done = step1Done && !!paidBy; + const splitValid = included.length > 0 && (splitType === 'equal' || remainderCents === 0); + const canSubmit = step2Done && splitValid; + + const fmtCents = (c: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(Math.abs(c) / 100); + + // ─ Helpers ─────────────────────────────────────────────────────────────── + + const toggleMember = (id: string) => { + setIncluded((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(''); - const amountNum = parseFloat(amount); - if (isNaN(amountNum) || amountNum <= 0) { - setError('Enter a valid amount'); - return; - } - if (selectedIds.length === 0) { - setError('Select at least one member'); - return; + const applyPreset = (preset: 'all' | 'just-them' | 'just-me') => { + if (preset === 'all') setIncluded(members.map((m) => m.id)); + else if (preset === 'just-me') setIncluded([currentMemberId]); + else if (preset === 'just-them') + setIncluded(members.filter((m) => m.id !== currentMemberId).map((m) => m.id)); + }; + + const distributeEvenly = () => { + if (included.length === 0 || amountNum === 0) return; + if (splitType === 'exact') { + const each = (amountNum / included.length).toFixed(2); + setExactAmounts(Object.fromEntries(included.map((id) => [id, each]))); + } else if (splitType === 'percentage') { + const each = String(Math.round(100 / included.length)); + setPercentages(Object.fromEntries(included.map((id) => [id, each]))); } + }; - let splits: ExactSplitInput[] | PercentageSplitInput[] | undefined; + const handleSubmit = async () => { + if (!canSubmit) return; + setError(''); + let splits: ExactSplitInput[] | PercentageSplitInput[] | undefined; if (splitType === 'exact') { - splits = selectedIds.map((id) => ({ + splits = included.map((id) => ({ memberId: id, amount: parseFloat(exactAmounts[id] ?? '0'), })); } else if (splitType === 'percentage') { - splits = selectedIds.map((id) => ({ + splits = included.map((id) => ({ memberId: id, percentage: parseFloat(percentages[id] ?? '0'), })); @@ -87,13 +221,7 @@ export function AddExpenseModal({ setLoading(true); try { - await onSave({ description, amount: amountNum, splitType, memberIds: selectedIds, splits }); - setDescription(''); - setAmount(''); - setSplitType('equal'); - setSelectedIds(members.map((m) => m.id)); - setExactAmounts({}); - setPercentages({}); + await onSave({ description, amount: amountNum, splitType, memberIds: included, splits }); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save expense'); @@ -102,107 +230,336 @@ export function AddExpenseModal({ } }; + // ─ Summary lines for collapsed steps ───────────────────────────────────── + + const step1Summary = step1Done ? `${fmtCents(amountCents)} Β· ${description}` : null; + const paidByName = members.find((m) => m.id === paidBy)?.displayName ?? ''; + const step2Summary = paidBy + ? paidBy === currentMemberId + ? 'You paid' + : `${paidByName.split(' ')[0]} paid` + : null; + const step3Summary = + included.length > 0 + ? splitType === 'equal' + ? `Equally between ${included.length}` + : `${splitType === 'exact' ? 'Custom $' : '%'} Β· ${included.length} people` + : null; + + // ─ Render ───────────────────────────────────────────────────────────────── + return ( - -
- setDescription(e.target.value)} - required - maxLength={200} - autoFocus - /> - setAmount(e.target.value)} - required - /> - -
- -
- {(['equal', 'exact', 'percentage'] as SplitType[]).map((t) => ( - - ))} -
+
+