awesome ui changes and expense split restructured

This commit is contained in:
shrt 2025-04-07 23:31:39 +02:00
parent 2ddbef6627
commit b1823f5294
18 changed files with 522 additions and 249 deletions

View File

@ -54,7 +54,8 @@
"tw-animate-css": "^1.2.5",
"use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",

26
pnpm-lock.yaml generated
View File

@ -119,6 +119,9 @@ importers:
zod:
specifier: ^3.24.2
version: 3.24.2
zustand:
specifier: ^5.0.3
version: 5.0.3(@types/react@19.1.0)(react@19.1.0)
devDependencies:
'@biomejs/biome':
specifier: 1.9.4
@ -1792,6 +1795,24 @@ packages:
zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
zustand@5.0.3:
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -3086,3 +3107,8 @@ snapshots:
isexe: 3.1.1
zod@3.24.2: {}
zustand@5.0.3(@types/react@19.1.0)(react@19.1.0):
optionalDependencies:
'@types/react': 19.1.0
react: 19.1.0

View File

@ -16,9 +16,7 @@ export default async function Page() {
Create Expense
</Button>
</Header>
<Section>
<ExpenseForm hideSubmit session={session!} />
</Section>
<ExpenseForm hideSubmit session={session!} />
</HydrateClient>
);
}

View File

@ -1,9 +1,12 @@
import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/server";
import Link from "next/link";
import React from "react";
export default function Page() {
export default async function Page() {
const splits = await api.expense.getAll();
return (
<>
<Header text="Expenses">
@ -11,6 +14,14 @@ export default function Page() {
<Link href="/add">Add Expense</Link>
</Button>
</Header>
<Section>
{splits.map(({ expense }) => (
<div key={expense.id}>
<h2>{expense.description}</h2>
<p>{expense.amount}</p>
</div>
))}
</Section>
</>
);
}

View File

@ -3,6 +3,7 @@ import Header from "@/components/header";
import Section from "@/components/section";
import { auth } from "@/server/auth";
import UserDropdown from "../_components/user-dropdown";
import { ModeToggle } from "@/components/mode-toggle";
export default async function Home() {
const session = await auth();
@ -12,7 +13,9 @@ export default async function Home() {
<Header text={appConfig.name}>
<UserDropdown user={session?.user!} />
</Header>
<Section></Section>
<Section>
<ModeToggle />
</Section>
</>
);
}

View File

@ -20,37 +20,92 @@ import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split";
import type { Session } from "next-auth";
import { useExpenseSplit } from "@/hooks/use-expense-split";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { useExpenseStore } from "@/lib/store/expense-store";
import ExpenseParticipants from "./expense-participants";
import { Separator } from "@/components/ui/separator";
import { calculateDepts } from "@/lib/utils/expense";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
function ExpenseForm({
initialExpense,
session,
initialExpense,
hideSubmit,
}: {
initialExpense?: Expense;
session: Session;
initialExpense?: Expense;
hideSubmit?: boolean;
}) {
const createExpense = api.expense.create.useMutation();
const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema),
defaultValues: {
description: initialExpense?.description ?? "",
amount: initialExpense?.amount ?? 0,
amount: initialExpense?.amount ? Number(initialExpense?.amount) : 0,
},
});
const participants = useExpenseStore((state) => state.participants);
const addParticipant = useExpenseStore((state) => state.addParticipant);
const setAmount = useExpenseStore((state) => state.setAmount);
const splitType = useExpenseStore((state) => state.splitType);
const setPayments = useExpenseStore((state) => state.setPayments);
const payments = useExpenseStore((state) => state.payments);
const recalculateSplits = useExpenseStore((state) => state.recalculateSplits);
const amount = form.watch("amount");
const expenseSplitHook = useExpenseSplit(amount, session);
function onSubmit(values: z.infer<typeof expenseSchema>) {
if (expenseSplitHook.participants.length <= 1)
const resetExpenseStore = useExpenseStore((state) => state.resetExpenseStore);
const router = useRouter();
const createExpense = api.expense.create.useMutation({
onSuccess(expense) {
form.reset();
resetExpenseStore();
addParticipant(session.user);
setPayments([{ amount: amount, userId: session.user.id }]);
toast.message("Expense Created", {
position: "bottom-center",
action: {
children: "test",
onClick: () => {
router.push(`/expense/${expense.id}`);
},
label: "View Expense",
},
});
},
});
function onSubmit(expense: z.infer<typeof expenseSchema>) {
if (createExpense.isPending) return;
if (participants.length <= 1)
return toast.error("Please add at least 2 participants");
if (expenseSplitHook.splitType !== "Equal")
if (splitType !== "Equal")
return toast.error("You can only split equal for now.");
toast.success("Expense created!");
const paymentAmount = payments.reduce((acc, curr) => acc + curr.amount, 0);
if (amount !== paymentAmount) return toast.error("Invalid payment amount");
const debs = calculateDepts(
amount,
participants.map((u) => u.id!),
payments
);
createExpense.mutate({ expense, debs });
}
React.useEffect(() => {
addParticipant(session.user);
setPayments([{ amount, userId: session.user.id }]);
}, []);
const handleAmountChange = (value: number) => {
setAmount(value);
const firstUserId = payments[0]?.userId ?? session.user.id;
setPayments([{ amount: value, userId: firstUserId }]);
recalculateSplits();
};
return (
<Form {...form}>
<form
@ -63,43 +118,59 @@ function ExpenseForm({
{initialExpense?.id?.length ? "Update" : "Create"} Expense
</Button>
)}
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel> Amount</FormLabel>
<div className="group flex border-b gap-2 px-4 py-2 border-input items-center ">
<NumberInput
value={field.value}
onChange={field.onChange}
className="focus-visible:ring-0 focus-visible:ring-offset-0 border-0 p-0"
/>
<EuroIcon className="size-6 text-muted-foreground" />
</div>
<FormMessage />
</FormItem>
)}
/>
<ExpenseParticipants session={session} />
<Separator />
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel className="ml-4">Description</FormLabel>
<FormControl>
<Textarea
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-muted-foreground border-0 border-b rounded-none resize-none"
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-muted-foreground border-0 border-b rounded-none resize-none px-6"
placeholder="About this expense"
{...field}
/>
</FormControl>
<FormMessage />
<FormMessage className="ml-6" />
</FormItem>
)}
/>
<ExpenseSplit expenseSplitHook={expenseSplitHook} session={session} />
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="flex flex-col justify-between h-full ">
<FormLabel className="ml-6"> Amount</FormLabel>
<div className="relative flex gap-2 items-center pt-4 ">
<NumberInput
value={field.value}
onChange={(value) => {
field.onChange(value);
handleAmountChange(value);
}}
className={cn(
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-muted-foreground border-0 pb-6 px-6 border-b rounded-none",
form.getFieldState(field.name).error && "border-destructive"
)}
/>
<EuroIcon
className={cn(
"size-6 text-muted-foreground absolute right-6 bottom-4",
form.getFieldState(field.name).error && "text-destructive"
)}
/>
</div>
<FormMessage className="ml-6" />
</FormItem>
)}
/>
<ExpenseSplit session={session} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0">
<TabsTrigger value="expense">Expense</TabsTrigger>

View File

@ -0,0 +1,57 @@
import Avatar from "@/components/avatar";
import { Input } from "@/components/ui/input";
import { useExpenseStore } from "@/lib/store/expense-store";
import React from "react";
import FriendSelect from "../friend/friend-select";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import type { Session } from "next-auth";
export default function ExpenseParticipants({ session }: { session: Session }) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const participants = useExpenseStore((state) => state.participants);
const addParticipant = useExpenseStore((state) => state.addParticipant);
const removeParticipant = useExpenseStore((state) => state.removeParticipant);
const excludeIds = participants.map((user) => user.id!);
return (
<div className="p-4 space-y-4">
<div className=" flex gap-2 w-full items-center flex-wrap">
<h5 className="w-18">You and</h5>
<FriendSelect
excludeIds={excludeIds}
onSelect={(userId) => {
addParticipant(
friends.find((friend) => friend.user.id === userId)!.user
);
}}
/>
{participants.map((user) => (
<ul
key={user.id}
className="border rounded-full gap-1 flex items-center w-max "
>
<Avatar src={user.image} fb={user.name} className="size-6" />
<span className="pr-4 text-sm">{user.name}</span>
{session.user.id !== user.id && (
<Button
className=" rounded-full aspect-square size-6"
variant="destructive"
onClick={(e) => {
e.preventDefault();
removeParticipant(user.id!);
}}
>
<XIcon className="size-4" />
</Button>
)}
</ul>
))}
</div>
</div>
);
}

View File

@ -4,50 +4,31 @@ import FriendSelect from "../friend/friend-select";
import { Button } from "@/components/ui/button";
import type { Session } from "next-auth";
import FriendCard from "../friend/friend-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Icons } from "@/components/icons";
import {
splitTypeKeys,
type ExpenseSplitHook,
type SplitType,
} from "@/hooks/use-expense-split";
import PaidByInput from "./paid-by";
import { Separator } from "@radix-ui/react-select";
export default function ExpenseSplit({
expenseSplitHook,
session,
}: {
expenseSplitHook: ExpenseSplitHook;
session: Session;
}) {
const {
friends,
splitType,
setSplitType,
friendTarget,
setFriendTarget,
addParticipant,
removeParticipant,
participants,
splits,
} = expenseSplitHook;
import { Icons } from "@/components/icons";
import { useExpenseStore } from "@/lib/store/expense-store";
import PaidByInput from "./paid-by";
import { api } from "@/trpc/react";
import SplitTo from "./split-to";
export default function ExpenseSplit({ session }: { session: Session }) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const friendTarget = useExpenseStore((state) => state.friendTarget);
const setFriendTarget = useExpenseStore((state) => state.setFriendTarget);
const addParticipant = useExpenseStore((state) => state.addParticipant);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<PaidByInput
expenseSplitHook={expenseSplitHook}
sessionUser={session.user}
/>
<div className="space-y-4 ">
<div className="flex items-center justify-center gap-2">
<span>Paid by</span>
<PaidByInput sessionUser={session.user} />
</div>
<Separator className=" bg-primary w-full h-px my-12" />
<div className="flex items-center justify-center gap-2">
<span>and spliited</span>
<SplitTo />
</div>
{/* <Separator className=" bg-primary w-full h-px my-12" />
<Select
value={splitType}
onValueChange={(currentValue) =>
@ -65,30 +46,7 @@ export default function ExpenseSplit({
))}
</SelectContent>
</Select>
<div className="flex gap-2 items-center w-full">
<FriendSelect
className="grow"
onSelect={setFriendTarget}
initialValue={friendTarget}
excludeIds={participants.map((user) => user.id!)}
/>
<Button
variant={"outline"}
size={"icon"}
onClick={(e) => {
e.preventDefault();
if (!friendTarget) return;
const newUser = friends.find(
(friend) => friend.user.id === friendTarget
)!;
addParticipant(newUser.user!);
setFriendTarget(undefined);
}}
>
<Icons.add className="size-4" />
</Button>
</div>
<h4 className="text-lg">Owed to</h4>
<ul className="space-y-2">
{participants.map((user) => (
@ -142,7 +100,7 @@ export default function ExpenseSplit({
</FriendCard>
</li>
))}
</ul>
</ul> */}
</div>
);
}

View File

@ -17,37 +17,74 @@ import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useExpenseStore } from "@/lib/store/expense-store";
function PaidByCustomSplit({
users,
payments,
}: {
users: Array<PublicUser>;
payments: Array<Payment>;
}) {
function PaidByCustomSplit() {
const [open, setOpen] = React.useState(false);
const [customAmounts, setCustomAmounts] = React.useState<
Record<string, number>
>({});
const error = Object.values(customAmounts).reduce(
(acc, curr) => acc + curr,
0
);
console.log(error);
const [error, setError] = React.useState<string>("");
const amount = useExpenseStore((state) => state.amount);
const users = useExpenseStore((state) => state.participants);
const setPayments = useExpenseStore((state) => state.setPayments);
const payments = useExpenseStore((state) => state.payments);
const handleSaveSplits = () => {
const customAmount = Object.values(customAmounts).reduce(
(acc, curr) => acc + curr,
0
);
if (customAmount !== amount) {
setError(
`Please enter the same amount as the expense. You entered ${customAmount}€ but the expense is ${amount}`
);
} else {
setOpen(false);
setError("");
var payments: Array<Payment> = [];
Object.keys(customAmounts).forEach((key) => {
const currentAmount = customAmounts[key];
if (Number(currentAmount) <= 0) return;
payments.push({ amount: currentAmount ?? 0, userId: key });
});
setPayments(payments);
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="mt-4">Multiple Friends</DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose>
<DialogContent
className=" absolute size-full sm:max-w flex-col flex"
hideClose
>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<Button
variant={"outline"}
className="ml-auto"
onClick={() => {
setCustomAmounts({});
setError("");
}}
>
Reset
</Button>
<DialogClose asChild>
<Button className="ml-auto">Done</Button>
<Button variant={"destructive"} className=" ml-2">
Cancel
</Button>
</DialogClose>
</div>
<DialogDescription className="text-sm text-destructive text-center">
{error}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2">
{users.map((user) => {
return (
<div
<li
key={user.id}
className="p-4 flex w-full h-max rounded-2xl border justify-between gap-2 overflow-hidden "
>
@ -74,10 +111,18 @@ function PaidByCustomSplit({
className="w-20 rounded-none border-0"
/>
</div>
</div>
</li>
);
})}
</DialogHeader>
</ul>
<Button
onClick={(e) => {
e.preventDefault();
handleSaveSplits();
}}
>
Save Custom Amounts
</Button>
</DialogContent>
</Dialog>
);

View File

@ -2,7 +2,6 @@ import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
@ -11,40 +10,42 @@ import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar";
import type { ExpenseSplitHook } from "@/hooks/use-expense-split";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split";
import { useExpenseStore } from "@/lib/store/expense-store";
/* saved result is an array of objects with user and amount */
export default function PaidByInput({
sessionUser,
expenseSplitHook,
}: {
sessionUser: PublicUser;
expenseSplitHook: ExpenseSplitHook;
}) {
const { amount, payments, setPayments, participants } = expenseSplitHook;
const amount = useExpenseStore((state) => state.amount);
const payments = useExpenseStore((state) => state.payments);
const setPayments = useExpenseStore((state) => state.setPayments);
const participants = useExpenseStore((state) => state.participants);
return (
<Dialog>
<DialogTrigger className="size-full flex items-center flex-wrap gap-2">
Paid by
<div className="py-1 rounded-md px-4 ">
<DialogTrigger className="flex items-center flex-wrap gap-2" asChild>
<Button size={"sm"} variant={"outline"} className="">
{payments.length > 1 ? (
"2+ Users"
) : (
<span className="text-xl">
{payments.find(({ userId }) => userId === sessionUser.id)
{(payments.find(({ userId }) => userId === sessionUser.id)
? "You"
: participants.find(({ id }) => id === payments[0]?.userId)
?.name}
?.name) ?? "Nope"}
</span>
)}
</div>
</Button>
</DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose>
<DialogContent
className=" absolute size-full sm:max-w flex-col flex"
hideClose
>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
@ -52,6 +53,8 @@ export default function PaidByInput({
<Button className="ml-auto">Go Back</Button>
</DialogClose>
</div>
</DialogHeader>
<ul className="space-y-2">
{participants.map((user) => {
const selected = payments.find(({ userId }) => userId === user.id);
return (
@ -87,8 +90,10 @@ export default function PaidByInput({
</Button>
);
})}
<PaidByCustomSplit payments={payments} users={participants} />
</DialogHeader>
<li className="w-max mx-auto">
<PaidByCustomSplit />
</li>
</ul>
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,39 @@
"use client";
import React from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useExpenseStore } from "@/lib/store/expense-store";
function SplitTo() {
const splitType = useExpenseStore((state) => state.splitType);
return (
<Dialog>
<DialogTrigger className="flex items-center flex-wrap gap-2" asChild>
<Button size={"sm"} variant={"outline"} className="">
{splitType}
</Button>
</DialogTrigger>
<DialogContent
className=" absolute size-full sm:max-w flex-col flex"
hideClose
>
<DialogHeader>
<div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle>
<DialogClose asChild>
<Button className="ml-auto">Go Back</Button>
</DialogClose>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
export default SplitTo;

View File

@ -47,6 +47,7 @@ export function Combobox({
className,
hideSearch,
buttonProps,
onSelect,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);

View File

@ -1,81 +0,0 @@
"use client";
import React from "react";
import { api } from "@/trpc/react";
import type { Session } from "next-auth";
import type { PublicUser } from "next-auth";
import { calculateSplits, type Payment } from "@/lib/utils/expense";
export enum SplitTypeEnum {
Fixed = "fixed",
Percentage = "percentage",
Equal = "equal",
}
export type SplitType = keyof typeof SplitTypeEnum;
export const splitTypeKeys = Object.keys(SplitTypeEnum);
export const useExpenseSplit = (amount: number, session: Session) => {
const [friends] = api.friend.getAll.useSuspenseQuery();
const [friendTarget, setFriendTarget] = React.useState<string>();
const [participants, setParticipants] = React.useState<Array<PublicUser>>([
session.user,
]);
const [activeSplitType, setActiveSplitType] =
React.useState<SplitType>("Equal");
const [payments, setPayments] = React.useState<Array<Payment>>([
{ amount: amount, userId: session.user.id },
]);
const [splits, setSplits] = React.useState<
Record<string /* user.id */, string /* amount */>
>({});
React.useEffect(() => {
// const generateSplitsAmount = () => {
// var newSplits: Record<string, SplitType> = {};
// participants.forEach((user) => {
// newSplits[user.id!] = splits[user.id!] ?? {};
// });
// console.log(newSplits);
// return newSplits;
// };
setSplits(
calculateSplits(
{
amount,
users: participants.map((user) => user.id!),
splitType: activeSplitType,
}
// generateSplitsAmount()
)
);
}, [participants, activeSplitType, amount]);
const addParticipant = (newUser: PublicUser) => {
setParticipants((prev) =>
prev.find((user) => user.id === newUser.id) ? prev : [...prev, newUser]
);
};
const removeParticipant = (userId: string) => {
setParticipants((prev) => prev.filter((user) => user.id !== userId));
};
return {
friends,
amount,
participants,
addParticipant,
removeParticipant,
friendTarget,
setFriendTarget,
splitType: activeSplitType,
setSplitType: setActiveSplitType,
payments,
setPayments,
//
splits,
};
};
export type ExpenseSplitHook = ReturnType<typeof useExpenseSplit>;

View File

@ -0,0 +1,96 @@
"use client";
import { create } from "zustand";
import { calculateSplits, type Payment } from "@/lib/utils/expense";
import type { Session } from "next-auth";
import type { PublicUser } from "next-auth";
export enum SplitTypeEnum {
Fixed = "fixed",
Percentage = "percentage",
Equal = "equal",
}
export type SplitType = keyof typeof SplitTypeEnum;
export const splitTypeKeys = Object.keys(SplitTypeEnum);
interface ExpenseStore {
amount: number;
session: Session | null;
participants: PublicUser[];
friendTarget?: string;
splitType: SplitType;
payments: Payment[];
splits: Record<string, string>;
setAmount: (amount: number) => void;
setSession: (session: Session) => void;
addParticipant: (user: PublicUser) => void;
removeParticipant: (userId: string) => void;
setFriendTarget: (id?: string) => void;
setSplitType: (type: SplitType) => void;
setPayments: (payments: Array<Payment>) => void;
recalculateSplits: () => void;
resetExpenseStore: () => void;
}
const defaultValues: Pick<
ExpenseStore,
| "amount"
| "participants"
| "friendTarget"
| "splitType"
| "payments"
| "splits"
> = {
amount: 0,
participants: [],
friendTarget: undefined,
splitType: "Equal",
payments: [],
splits: {},
};
export const useExpenseStore = create<ExpenseStore>((set, get) => ({
...defaultValues,
session: null,
setAmount: (amount) => set({ amount }),
setSession: (session) =>
set(
{
session,
participants: [session.user],
payments: [{ amount: get().amount, userId: session.user.id }],
},
false
),
addParticipant: (newUser) => {
const { participants } = get();
if (!participants.find((user) => user.id === newUser.id)) {
set({ participants: [...participants, newUser] });
}
},
removeParticipant: (userId) => {
const filtered = get().participants.filter((user) => user.id !== userId);
set({ participants: filtered });
},
setFriendTarget: (id) => set({ friendTarget: id }),
setSplitType: (type) => set({ splitType: type }),
setPayments: (payments) => set({ payments }),
recalculateSplits: () => {
const { amount, participants, splitType } = get();
const splits = calculateSplits({
amount,
users: participants.map((u) => u.id!),
splitType,
});
set({ splits });
},
resetExpenseStore: () => set(defaultValues),
}));

View File

@ -1,10 +1,13 @@
import type { SplitType } from "@/hooks/use-expense-split";
import type { SplitType } from "@/lib/store/expense-store";
import type { z } from "zod";
import { expenseSplitSchema } from "../validations/expense";
export type Debt = {
from: string;
to: string;
amount: number;
};
const debtSchema = expenseSplitSchema.pick({
owedFromId: true,
owedToId: true,
amount: true,
});
export type Debt = z.infer<typeof debtSchema>;
export type Payment = {
userId: string;
@ -18,8 +21,6 @@ export function calculateSplits(args: {
}): Record<string, string> {
const result: Record<string, string> = {};
const { splitType = "Equal", amount, users, payments } = args;
// If splits are provided, validate them
// const amount = Object.values(payments).reduce((sum, value) => sum + value, 0);
switch (splitType) {
case "Equal":
@ -69,7 +70,7 @@ export function calculateDepts(
total: number,
users: string[],
payments: Array<Payment>
): Debt[] {
): Array<Debt> {
console.log("Payments: ", payments);
const share = total / users.length;
@ -90,6 +91,8 @@ export function calculateDepts(
const debtors = Object.entries(balances)
.filter(([_, balance]) => balance < 0)
.map(([userId, balance]) => ({ userId, balance: -balance }));
console.log("debtors: ", debtors);
console.log("creditors: ", creditors);
const debts: Debt[] = [];
@ -103,8 +106,8 @@ export function calculateDepts(
const payment = Math.min(amountToPay, creditor.balance);
debts.push({
from: debtor.userId,
to: creditor.userId,
owedToId: debtor.userId,
owedFromId: creditor.userId,
amount: parseFloat(payment.toFixed(2)), // for rounding safety
});

View File

@ -1,14 +1,15 @@
import { z } from "zod";
const amount = z.number().min(0.01);
export const expenseSchema = z.object({
amount: z.number().min(0.01),
description: z.string().optional(),
amount,
description: z.string().nonempty({ message: "Description is required" }),
});
export const expenseSplitSchema = z.object({
expenseId: z.string(),
paidById: z.string().min(1),
owedById: z.string().min(1),
amount: z.number().min(1),
owedToId: z.string().min(1),
owedFromId: z.string().min(1),
amount,
status: z.string(),
});

View File

@ -1,18 +1,57 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { expenses } from "@/server/db/schema";
import { expenses, expenseSplits, type ExpenseSplit } from "@/server/db/schema";
import { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
import { eq, or } from "drizzle-orm";
export const expenseRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db.query.expenseSplits.findMany({
where: or(
eq(expenseSplits.owedFromId, ctx.session.user.id),
eq(expenseSplits.owedToId, ctx.session.user.id)
),
with: {
expense: true,
},
});
}),
create: protectedProcedure
.input(
z.object({ expense: expenseSchema, split: z.array(expenseSplitSchema) })
z.object({
expense: expenseSchema,
debs: z.array(
expenseSplitSchema.pick({
owedFromId: true,
owedToId: true,
amount: true,
})
),
})
)
.mutation(async ({ ctx, input }) => {
const [expense] = await ctx.db.insert(expenses).values({
createdById: ctx.session.user.id,
...input.expense,
});
const [expense] = await ctx.db
.insert(expenses)
.values({
createdById: ctx.session.user.id,
...input.expense,
amount: input.expense.amount.toString(),
})
.returning({ id: expenses.id });
if (!expense?.id?.length) throw new Error("Expense cant get created");
await ctx.db
.insert(expenseSplits)
.values(
input.debs.map((deb) => ({
...deb,
amount: deb.amount.toString(),
expenseId: expense.id,
status: "unpaid",
}))
)
.returning({ id: expenseSplits.id });
return expense;
}),
});

View File

@ -24,7 +24,7 @@ export const expenses = createTable(
.uuid()
.notNull()
.references(() => users.id),
amount: d.integer().notNull(),
amount: d.numeric({ scale: 2 }),
currency: d.varchar({ length: 3 }).notNull().default("EUR"), // Default to EUR
description: d.varchar({ length: 512 }),
createdAt: d
@ -69,15 +69,15 @@ export const expenseSplits = createTable(
.uuid()
.notNull()
.references(() => expenses.id, { onDelete: "cascade" }),
paidById: d
owedToId: d
.uuid()
.notNull()
.references(() => users.id),
owedById: d
owedFromId: d
.uuid()
.notNull()
.references(() => users.id),
amount: d.integer().notNull(),
amount: d.numeric({ scale: 2 }),
status: d.varchar({ length: 10 }).notNull().default("unpaid"), // 'unpaid' | 'settled'
createdAt: d
.timestamp({ withTimezone: true })
@ -92,12 +92,12 @@ export const expenseSplitRelations = relations(expenseSplits, ({ one }) => ({
fields: [expenseSplits.expenseId],
references: [expenses.id],
}),
paidBy: one(users, {
fields: [expenseSplits.paidById],
owedTo: one(users, {
fields: [expenseSplits.owedToId],
references: [users.id],
}),
owedBy: one(users, {
fields: [expenseSplits.paidById],
owedFrom: one(users, {
fields: [expenseSplits.owedFromId],
references: [users.id],
}),
}));
@ -123,7 +123,7 @@ export const settlements = createTable(
.uuid()
.notNull()
.references(() => users.id),
amount: d.integer().notNull(),
amount: d.numeric().notNull(),
currency: d.varchar({ length: 3 }).notNull().default("EUR"),
createdAt: d
.timestamp({ withTimezone: true })