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", "tw-animate-css": "^1.2.5",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^3.24.2" "zod": "^3.24.2",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",

26
pnpm-lock.yaml generated
View File

@ -119,6 +119,9 @@ importers:
zod: zod:
specifier: ^3.24.2 specifier: ^3.24.2
version: 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: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 1.9.4 specifier: 1.9.4
@ -1792,6 +1795,24 @@ packages:
zod@3.24.2: zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} 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: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@ -3086,3 +3107,8 @@ snapshots:
isexe: 3.1.1 isexe: 3.1.1
zod@3.24.2: {} 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 Create Expense
</Button> </Button>
</Header> </Header>
<Section> <ExpenseForm hideSubmit session={session!} />
<ExpenseForm hideSubmit session={session!} />
</Section>
</HydrateClient> </HydrateClient>
); );
} }

View File

@ -1,9 +1,12 @@
import Header from "@/components/header"; import Header from "@/components/header";
import Section from "@/components/section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/trpc/server";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
export default function Page() { export default async function Page() {
const splits = await api.expense.getAll();
return ( return (
<> <>
<Header text="Expenses"> <Header text="Expenses">
@ -11,6 +14,14 @@ export default function Page() {
<Link href="/add">Add Expense</Link> <Link href="/add">Add Expense</Link>
</Button> </Button>
</Header> </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 Section from "@/components/section";
import { auth } from "@/server/auth"; import { auth } from "@/server/auth";
import UserDropdown from "../_components/user-dropdown"; import UserDropdown from "../_components/user-dropdown";
import { ModeToggle } from "@/components/mode-toggle";
export default async function Home() { export default async function Home() {
const session = await auth(); const session = await auth();
@ -12,7 +13,9 @@ export default async function Home() {
<Header text={appConfig.name}> <Header text={appConfig.name}>
<UserDropdown user={session?.user!} /> <UserDropdown user={session?.user!} />
</Header> </Header>
<Section></Section> <Section>
<ModeToggle />
</Section>
</> </>
); );
} }

View File

@ -20,37 +20,92 @@ import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react"; import { EuroIcon } from "lucide-react";
import ExpenseSplit from "./expense-split"; import ExpenseSplit from "./expense-split";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { useExpenseSplit } from "@/hooks/use-expense-split";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "@/trpc/react"; 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({ function ExpenseForm({
initialExpense,
session, session,
initialExpense,
hideSubmit, hideSubmit,
}: { }: {
initialExpense?: Expense;
session: Session; session: Session;
initialExpense?: Expense;
hideSubmit?: boolean; hideSubmit?: boolean;
}) { }) {
const createExpense = api.expense.create.useMutation();
const form = useForm<z.infer<typeof expenseSchema>>({ const form = useForm<z.infer<typeof expenseSchema>>({
resolver: zodResolver(expenseSchema), resolver: zodResolver(expenseSchema),
defaultValues: { defaultValues: {
description: initialExpense?.description ?? "", 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 amount = form.watch("amount");
const expenseSplitHook = useExpenseSplit(amount, session); const resetExpenseStore = useExpenseStore((state) => state.resetExpenseStore);
function onSubmit(values: z.infer<typeof expenseSchema>) { const router = useRouter();
if (expenseSplitHook.participants.length <= 1)
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"); 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."); 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 ( return (
<Form {...form}> <Form {...form}>
<form <form
@ -63,43 +118,59 @@ function ExpenseForm({
{initialExpense?.id?.length ? "Update" : "Create"} Expense {initialExpense?.id?.length ? "Update" : "Create"} Expense
</Button> </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 "> <ExpenseParticipants session={session} />
<NumberInput <Separator />
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>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel className="ml-4">Description</FormLabel>
<FormControl> <FormControl>
<Textarea <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" placeholder="About this expense"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage className="ml-6" />
</FormItem> </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"> {/* <Tabs defaultValue="expense" className="size-full space-y-4">
<TabsList className="w-full bg-transparent p-0"> <TabsList className="w-full bg-transparent p-0">
<TabsTrigger value="expense">Expense</TabsTrigger> <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 { Button } from "@/components/ui/button";
import type { Session } from "next-auth"; 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({ import { Icons } from "@/components/icons";
expenseSplitHook, import { useExpenseStore } from "@/lib/store/expense-store";
session, import PaidByInput from "./paid-by";
}: { import { api } from "@/trpc/react";
expenseSplitHook: ExpenseSplitHook; import SplitTo from "./split-to";
session: Session;
}) { export default function ExpenseSplit({ session }: { session: Session }) {
const { const [friends] = api.friend.getAll.useSuspenseQuery();
friends,
splitType, const friendTarget = useExpenseStore((state) => state.friendTarget);
setSplitType, const setFriendTarget = useExpenseStore((state) => state.setFriendTarget);
friendTarget, const addParticipant = useExpenseStore((state) => state.addParticipant);
setFriendTarget,
addParticipant,
removeParticipant,
participants,
splits,
} = expenseSplitHook;
return ( return (
<div className="space-y-4"> <div className="space-y-4 ">
<div className="flex items-center gap-2"> <div className="flex items-center justify-center gap-2">
<PaidByInput <span>Paid by</span>
expenseSplitHook={expenseSplitHook} <PaidByInput sessionUser={session.user} />
sessionUser={session.user}
/>
</div> </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 <Select
value={splitType} value={splitType}
onValueChange={(currentValue) => onValueChange={(currentValue) =>
@ -65,30 +46,7 @@ export default function ExpenseSplit({
))} ))}
</SelectContent> </SelectContent>
</Select> </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> <h4 className="text-lg">Owed to</h4>
<ul className="space-y-2"> <ul className="space-y-2">
{participants.map((user) => ( {participants.map((user) => (
@ -142,7 +100,7 @@ export default function ExpenseSplit({
</FriendCard> </FriendCard>
</li> </li>
))} ))}
</ul> </ul> */}
</div> </div>
); );
} }

View File

@ -17,37 +17,74 @@ import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/number-input"; import { NumberInput } from "@/components/number-input";
import { EuroIcon } from "lucide-react"; import { EuroIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useExpenseStore } from "@/lib/store/expense-store";
function PaidByCustomSplit({ function PaidByCustomSplit() {
users, const [open, setOpen] = React.useState(false);
payments,
}: {
users: Array<PublicUser>;
payments: Array<Payment>;
}) {
const [customAmounts, setCustomAmounts] = React.useState< const [customAmounts, setCustomAmounts] = React.useState<
Record<string, number> Record<string, number>
>({}); >({});
const error = Object.values(customAmounts).reduce( const [error, setError] = React.useState<string>("");
(acc, curr) => acc + curr, const amount = useExpenseStore((state) => state.amount);
0 const users = useExpenseStore((state) => state.participants);
); const setPayments = useExpenseStore((state) => state.setPayments);
console.log(error); 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 ( return (
<Dialog> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="mt-4">Multiple Friends</DialogTrigger> <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> <DialogHeader>
<div className="flex items-center mb-8"> <div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle> <DialogTitle className="text-2xl">Who paid?</DialogTitle>
<Button
variant={"outline"}
className="ml-auto"
onClick={() => {
setCustomAmounts({});
setError("");
}}
>
Reset
</Button>
<DialogClose asChild> <DialogClose asChild>
<Button className="ml-auto">Done</Button> <Button variant={"destructive"} className=" ml-2">
Cancel
</Button>
</DialogClose> </DialogClose>
</div> </div>
<DialogDescription className="text-sm text-destructive text-center">
{error}
</DialogDescription>
</DialogHeader>
<ul className="space-y-2">
{users.map((user) => { {users.map((user) => {
return ( return (
<div <li
key={user.id} key={user.id}
className="p-4 flex w-full h-max rounded-2xl border justify-between gap-2 overflow-hidden " 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" className="w-20 rounded-none border-0"
/> />
</div> </div>
</div> </li>
); );
})} })}
</DialogHeader> </ul>
<Button
onClick={(e) => {
e.preventDefault();
handleSaveSplits();
}}
>
Save Custom Amounts
</Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -2,7 +2,6 @@ import React from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@ -11,40 +10,42 @@ import { Button } from "@/components/ui/button";
import { DialogClose } from "@radix-ui/react-dialog"; import { DialogClose } from "@radix-ui/react-dialog";
import type { PublicUser } from "next-auth"; import type { PublicUser } from "next-auth";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import type { ExpenseSplitHook } from "@/hooks/use-expense-split";
import { Icons } from "@/components/icons"; import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import PaidByCustomSplit from "./paid-by-custom-split"; 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 */ /* saved result is an array of objects with user and amount */
export default function PaidByInput({ export default function PaidByInput({
sessionUser, sessionUser,
expenseSplitHook,
}: { }: {
sessionUser: PublicUser; 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 ( return (
<Dialog> <Dialog>
<DialogTrigger className="size-full flex items-center flex-wrap gap-2"> <DialogTrigger className="flex items-center flex-wrap gap-2" asChild>
Paid by <Button size={"sm"} variant={"outline"} className="">
<div className="py-1 rounded-md px-4 ">
{payments.length > 1 ? ( {payments.length > 1 ? (
"2+ Users" "2+ Users"
) : ( ) : (
<span className="text-xl"> <span className="text-xl">
{payments.find(({ userId }) => userId === sessionUser.id) {(payments.find(({ userId }) => userId === sessionUser.id)
? "You" ? "You"
: participants.find(({ id }) => id === payments[0]?.userId) : participants.find(({ id }) => id === payments[0]?.userId)
?.name} ?.name) ?? "Nope"}
</span> </span>
)} )}
</div> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className=" absolute size-full sm:max-w" hideClose> <DialogContent
className=" absolute size-full sm:max-w flex-col flex"
hideClose
>
<DialogHeader> <DialogHeader>
<div className="flex items-center mb-8"> <div className="flex items-center mb-8">
<DialogTitle className="text-2xl">Who paid?</DialogTitle> <DialogTitle className="text-2xl">Who paid?</DialogTitle>
@ -52,6 +53,8 @@ export default function PaidByInput({
<Button className="ml-auto">Go Back</Button> <Button className="ml-auto">Go Back</Button>
</DialogClose> </DialogClose>
</div> </div>
</DialogHeader>
<ul className="space-y-2">
{participants.map((user) => { {participants.map((user) => {
const selected = payments.find(({ userId }) => userId === user.id); const selected = payments.find(({ userId }) => userId === user.id);
return ( return (
@ -87,8 +90,10 @@ export default function PaidByInput({
</Button> </Button>
); );
})} })}
<PaidByCustomSplit payments={payments} users={participants} /> <li className="w-max mx-auto">
</DialogHeader> <PaidByCustomSplit />
</li>
</ul>
</DialogContent> </DialogContent>
</Dialog> </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, className,
hideSearch, hideSearch,
buttonProps, buttonProps,
onSelect, onSelect,
}: ComboboxProps) { }: ComboboxProps) {
const [open, setOpen] = React.useState(false); 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 = { const debtSchema = expenseSplitSchema.pick({
from: string; owedFromId: true,
to: string; owedToId: true,
amount: number; amount: true,
}; });
export type Debt = z.infer<typeof debtSchema>;
export type Payment = { export type Payment = {
userId: string; userId: string;
@ -18,8 +21,6 @@ export function calculateSplits(args: {
}): Record<string, string> { }): Record<string, string> {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
const { splitType = "Equal", amount, users, payments } = args; 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) { switch (splitType) {
case "Equal": case "Equal":
@ -69,7 +70,7 @@ export function calculateDepts(
total: number, total: number,
users: string[], users: string[],
payments: Array<Payment> payments: Array<Payment>
): Debt[] { ): Array<Debt> {
console.log("Payments: ", payments); console.log("Payments: ", payments);
const share = total / users.length; const share = total / users.length;
@ -90,6 +91,8 @@ export function calculateDepts(
const debtors = Object.entries(balances) const debtors = Object.entries(balances)
.filter(([_, balance]) => balance < 0) .filter(([_, balance]) => balance < 0)
.map(([userId, balance]) => ({ userId, balance: -balance })); .map(([userId, balance]) => ({ userId, balance: -balance }));
console.log("debtors: ", debtors);
console.log("creditors: ", creditors);
const debts: Debt[] = []; const debts: Debt[] = [];
@ -103,8 +106,8 @@ export function calculateDepts(
const payment = Math.min(amountToPay, creditor.balance); const payment = Math.min(amountToPay, creditor.balance);
debts.push({ debts.push({
from: debtor.userId, owedToId: debtor.userId,
to: creditor.userId, owedFromId: creditor.userId,
amount: parseFloat(payment.toFixed(2)), // for rounding safety amount: parseFloat(payment.toFixed(2)), // for rounding safety
}); });

View File

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

View File

@ -1,18 +1,57 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; 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 { expenseSchema, expenseSplitSchema } from "@/lib/validations/expense";
import { eq, or } from "drizzle-orm";
export const expenseRouter = createTRPCRouter({ 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 create: protectedProcedure
.input( .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 }) => { .mutation(async ({ ctx, input }) => {
const [expense] = await ctx.db.insert(expenses).values({ const [expense] = await ctx.db
createdById: ctx.session.user.id, .insert(expenses)
...input.expense, .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() .uuid()
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
amount: d.integer().notNull(), amount: d.numeric({ scale: 2 }),
currency: d.varchar({ length: 3 }).notNull().default("EUR"), // Default to EUR currency: d.varchar({ length: 3 }).notNull().default("EUR"), // Default to EUR
description: d.varchar({ length: 512 }), description: d.varchar({ length: 512 }),
createdAt: d createdAt: d
@ -69,15 +69,15 @@ export const expenseSplits = createTable(
.uuid() .uuid()
.notNull() .notNull()
.references(() => expenses.id, { onDelete: "cascade" }), .references(() => expenses.id, { onDelete: "cascade" }),
paidById: d owedToId: d
.uuid() .uuid()
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
owedById: d owedFromId: d
.uuid() .uuid()
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
amount: d.integer().notNull(), amount: d.numeric({ scale: 2 }),
status: d.varchar({ length: 10 }).notNull().default("unpaid"), // 'unpaid' | 'settled' status: d.varchar({ length: 10 }).notNull().default("unpaid"), // 'unpaid' | 'settled'
createdAt: d createdAt: d
.timestamp({ withTimezone: true }) .timestamp({ withTimezone: true })
@ -92,12 +92,12 @@ export const expenseSplitRelations = relations(expenseSplits, ({ one }) => ({
fields: [expenseSplits.expenseId], fields: [expenseSplits.expenseId],
references: [expenses.id], references: [expenses.id],
}), }),
paidBy: one(users, { owedTo: one(users, {
fields: [expenseSplits.paidById], fields: [expenseSplits.owedToId],
references: [users.id], references: [users.id],
}), }),
owedBy: one(users, { owedFrom: one(users, {
fields: [expenseSplits.paidById], fields: [expenseSplits.owedFromId],
references: [users.id], references: [users.id],
}), }),
})); }));
@ -123,7 +123,7 @@ export const settlements = createTable(
.uuid() .uuid()
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
amount: d.integer().notNull(), amount: d.numeric().notNull(),
currency: d.varchar({ length: 3 }).notNull().default("EUR"), currency: d.varchar({ length: 3 }).notNull().default("EUR"),
createdAt: d createdAt: d
.timestamp({ withTimezone: true }) .timestamp({ withTimezone: true })