started add Group to expense ui; ui improvments

This commit is contained in:
mr-shortman 2025-04-13 23:17:43 +02:00
parent 3fc08a3c10
commit 3f4cc126ec
12 changed files with 275 additions and 84 deletions

View File

@ -27,3 +27,9 @@ You can check out the [create-t3-app GitHub repository](https://github.com/t3-os
## How do I deploy this? ## How do I deploy this?
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
to test webhooks locally
```bash
ngrok http --url=nkrok-url 3000
```

View File

@ -1,22 +1,32 @@
import { appConfig } from "@/app.config"; import { appConfig } from "@/app.config";
import Header from "@/components/header"; import Header from "@/components/header";
import Section from "@/components/section"; import Section from "@/components/section";
import UserDropdown from "../_components/user-dropdown";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { UserButton } from "@clerk/nextjs"; import { UserButton } from "@clerk/nextjs";
import { Icons } from "@/components/icons";
export default async function Home() { export default async function Home() {
// const session = await auth();
return ( return (
<> <>
<Header text={appConfig.name}> <Header
<UserButton /> text={
{/* <UserDropdown user={session?.user!} /> */} <>
<Icons.logo
className="size-32 scale-125 mr-2 absolute -left-4 -top-1/2 text-muted-foreground/5 -z-10
mask-r-from-30%
"
/>
<span>{appConfig.name}</span>
<ModeToggle className="ml-4" />
</>
}
>
<div className="flex gap-6 items-center ">
<UserButton />
</div>
</Header> </Header>
<Section> <Section></Section>
<ModeToggle />
</Section>
</> </>
); );
} }

View File

@ -15,7 +15,6 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { expenseSchema } from "@/lib/validations/expense"; import { expenseSchema } from "@/lib/validations/expense";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { NumberInput } from "@/components/number-input"; 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";
@ -118,9 +117,6 @@ function ExpenseForm({
</Button> </Button>
)} )}
<ExpenseParticipants sessionUserId={sessionUser.id} />
<Separator />
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
@ -168,6 +164,9 @@ function ExpenseForm({
</FormItem> </FormItem>
)} )}
/> />
<ExpenseParticipants sessionUserId={sessionUser.id} />
<Separator />
<ExpenseSplit sessionUser={sessionUser} /> <ExpenseSplit sessionUser={sessionUser} />
{/* <Tabs defaultValue="expense" className="size-full space-y-4"> {/* <Tabs defaultValue="expense" className="size-full space-y-4">

View File

@ -1,3 +1,4 @@
"use client";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useExpenseStore } from "@/lib/store/expense-store"; import { useExpenseStore } from "@/lib/store/expense-store";
@ -7,6 +8,7 @@ import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import type { User } from "@/server/db/schema"; import type { User } from "@/server/db/schema";
import AddParticipants from "./select-participants";
export const UserBadge = ({ export const UserBadge = ({
user, user,
@ -30,7 +32,6 @@ export default function ExpenseParticipants({
}: { }: {
sessionUserId: string; sessionUserId: string;
}) { }) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const participants = useExpenseStore((state) => state.participants); const participants = useExpenseStore((state) => state.participants);
const addParticipant = useExpenseStore((state) => state.addParticipant); const addParticipant = useExpenseStore((state) => state.addParticipant);
const removeParticipant = useExpenseStore((state) => state.removeParticipant); const removeParticipant = useExpenseStore((state) => state.removeParticipant);
@ -38,37 +39,32 @@ export default function ExpenseParticipants({
return ( return (
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className=" flex gap-2 w-full items-center flex-wrap"> {participants.map((user) => (
<h5 className="w-18">You and</h5> <ul key={user.id} className="flex gap-2 w-full items-center flex-wrap">
<UserBadge user={user}>
<FriendSelect {sessionUserId !== user.id && (
excludeIds={excludeIds} <Button
onSelect={(userId) => { className=" rounded-full aspect-square size-6"
addParticipant( variant="destructive"
friends.find((friend) => friend.user.id === userId)!.user onClick={(e) => {
); e.preventDefault();
}} removeParticipant(user.id!);
/> }}
>
{participants.map((user) => ( <XIcon className="size-4" />
<ul key={user.id}> </Button>
<UserBadge user={user}> )}
{sessionUserId !== user.id && ( </UserBadge>
<Button </ul>
className=" rounded-full aspect-square size-6" ))}
variant="destructive" <AddParticipants
onClick={(e) => { excludeIds={excludeIds}
e.preventDefault(); onAdd={(users) => {
removeParticipant(user.id!); // addParticipant(
}} // friends.find((friend) => friend.user.id === userId)!.user
> // );
<XIcon className="size-4" /> }}
</Button> />
)}
</UserBadge>
</ul>
))}
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,42 @@
import React from "react";
import { Combobox } from "@/components/combobox";
import { api } from "@/trpc/react";
import Avatar from "@/components/avatar";
import type { User } from "@/server/db/schema";
export default function AddParticipants({
onAdd,
initialValue,
className,
excludeIds,
}: {
onAdd: (users: Array<User>) => void;
initialValue?: string;
className?: string;
excludeIds?: Array<string>;
}) {
const [friends] = api.friend.getAll.useSuspenseQuery();
const friendsData = friends
.filter((friend) => !excludeIds?.includes(friend.user.id))
.map(({ user }) => ({
label: user.name!,
value: user.id,
children: <Avatar src={user.image} fb={user.name} className="size-6" />,
}));
return (
// Custom Combobox needed mby go with command dialog
<Combobox
className={"w-full "}
data={friendsData}
onSelect={(userId) => onAdd([])}
initialValue={initialValue}
messageUi={{
select: "Add Friends or Groups",
empty: "No friends or groups found",
placeholder: "Search friends or groups",
}}
/>
);
}

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Combobox } from "@/components/combobox"; import { Combobox } from "@/components/combobox";
import { Icons } from "@/components/icons";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { Icons } from "@/components/icons";
import React from "react"; import React from "react";
@ -34,7 +34,6 @@ export default function FriendSelect({
initialValue={initialValue} initialValue={initialValue}
messageUi={{ messageUi={{
select: "Select Friend", select: "Select Friend",
empty: "No friends found", empty: "No friends found",
placeholder: "Search friends", placeholder: "Search friends",
}} }}

View File

@ -82,7 +82,7 @@ export default function AddToGroupDrawer({
return ( return (
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button size={"sm"} className={className}> <Button size={"sm"} className={className} variant={"outline"}>
Add People Add People
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>

View File

@ -46,7 +46,7 @@ function GroupPage({ groupId }: { groupId: string }) {
<div className="flex gap-4 w-full items-center"> <div className="flex gap-4 w-full items-center">
<h4 className="text-lg font-semibold">Expenses</h4> <h4 className="text-lg font-semibold">Expenses</h4>
<Button asChild size={"sm"} className="ml-auto"> <Button asChild size={"sm"} className="ml-auto" variant={"outline"}>
<Link href={`/add?groupId=${group.id}`}> <Link href={`/add?groupId=${group.id}`}>
<Icons.wallet /> <Icons.wallet />
<span>Add Expense</span> <span>Add Expense</span>

View File

@ -5,13 +5,15 @@ export default function Header({
children, children,
glow = true, glow = true,
}: { }: {
text: string; text: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
glow?: boolean; glow?: boolean;
}) { }) {
return ( return (
<div className="w-full p-4 border-b flex justify-between items-center relative"> <div className="w-full p-4 border-b flex justify-between items-center relative overflow-hidden h-16">
<h2 className="font-black text-2xl uppercase">{text}</h2> <div className="font-black text-2xl uppercase flex items-center">
{text}
</div>
{children} {children}
{/* Glow effect */} {/* Glow effect */}

View File

@ -14,6 +14,9 @@ type IconName =
| "user" | "user"
| "addSquare" | "addSquare"
| "check" | "check"
| "sun"
| "moon"
| "display"
| "loading"; | "loading";
export const Icons: Record<IconName, IconComponent> = { export const Icons: Record<IconName, IconComponent> = {
@ -285,6 +288,99 @@ export const Icons: Record<IconName, IconComponent> = {
</svg> </svg>
); );
}, },
sun(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_197_4386)">
<path
d="M12.75 1C12.75 0.585786 12.4142 0.25 12 0.25C11.5858 0.25 11.25 0.585786 11.25 1V3C11.25 3.41421 11.5858 3.75 12 3.75C12.4142 3.75 12.75 3.41421 12.75 3V1Z"
fill="currentColor"
/>
<path
d="M4.75216 3.69149C4.45926 3.3986 3.98439 3.3986 3.6915 3.69149C3.3986 3.98439 3.3986 4.45926 3.6915 4.75216L5.10571 6.16637C5.3986 6.45926 5.87348 6.45926 6.16637 6.16637C6.45926 5.87348 6.45926 5.3986 6.16637 5.10571L4.75216 3.69149Z"
fill="currentColor"
/>
<path
d="M20.3085 4.75216C20.6014 4.45926 20.6014 3.98439 20.3085 3.6915C20.0156 3.3986 19.5407 3.3986 19.2478 3.69149L17.8336 5.10571C17.5407 5.3986 17.5407 5.87348 17.8336 6.16637C18.1265 6.45926 18.6014 6.45926 18.8943 6.16637L20.3085 4.75216Z"
fill="currentColor"
/>
<path
d="M12 5.25C8.27208 5.25 5.25 8.27208 5.25 12C5.25 15.7279 8.27208 18.75 12 18.75C15.7279 18.75 18.75 15.7279 18.75 12C18.75 8.27208 15.7279 5.25 12 5.25Z"
fill="currentColor"
/>
<path
d="M1 11.25C0.585786 11.25 0.25 11.5858 0.25 12C0.25 12.4142 0.585786 12.75 1 12.75H3C3.41421 12.75 3.75 12.4142 3.75 12C3.75 11.5858 3.41421 11.25 3 11.25H1Z"
fill="currentColor"
/>
<path
d="M21 11.25C20.5858 11.25 20.25 11.5858 20.25 12C20.25 12.4142 20.5858 12.75 21 12.75H23C23.4142 12.75 23.75 12.4142 23.75 12C23.75 11.5858 23.4142 11.25 23 11.25H21Z"
fill="currentColor"
/>
<path
d="M6.16637 18.8943C6.45926 18.6014 6.45926 18.1265 6.16637 17.8336C5.87348 17.5407 5.3986 17.5407 5.10571 17.8336L3.6915 19.2478C3.3986 19.5407 3.3986 20.0156 3.6915 20.3085C3.98439 20.6014 4.45926 20.6014 4.75216 20.3085L6.16637 18.8943Z"
fill="currentColor"
/>
<path
d="M18.8943 17.8336C18.6014 17.5407 18.1265 17.5407 17.8336 17.8336C17.5407 18.1265 17.5407 18.6014 17.8336 18.8943L19.2478 20.3085C19.5407 20.6014 20.0156 20.6014 20.3085 20.3085C20.6014 20.0156 20.6014 19.5407 20.3085 19.2478L18.8943 17.8336Z"
fill="currentColor"
/>
<path
d="M12.75 21C12.75 20.5858 12.4142 20.25 12 20.25C11.5858 20.25 11.25 20.5858 11.25 21V23C11.25 23.4142 11.5858 23.75 12 23.75C12.4142 23.75 12.75 23.4142 12.75 23V21Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_197_4386">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
},
moon(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12.9191 12.0134C11.051 8.77773 11.3963 5.88815 11.6711 4.0347L11.6997 3.84335C11.7573 3.46024 11.8126 3.09144 11.8314 2.7912C11.8425 2.61306 11.8442 2.41736 11.8136 2.22926C11.7836 2.04435 11.7113 1.79715 11.5173 1.58811C11.0765 1.11318 10.4574 1.24503 10.1231 1.35182C9.72572 1.47873 9.22917 1.7308 8.62667 2.07865C3.48503 5.04718 1.72337 11.6218 4.6919 16.7634C7.66043 21.9051 14.235 23.6667 19.3767 20.6982C19.9792 20.3503 20.4457 20.0464 20.7543 19.7657C21.014 19.5295 21.4377 19.0593 21.2468 18.4401C21.1628 18.1676 20.9848 17.9814 20.8397 17.8629C20.6921 17.7424 20.5218 17.646 20.362 17.5665C20.0926 17.4327 19.7455 17.2962 19.385 17.1545L19.2049 17.0836C17.4624 16.3949 14.7873 15.2491 12.9191 12.0134Z"
fill="currentColor"
/>
</svg>
);
},
display(props) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.0336 2.25C16.4053 2.25 17.4807 2.24999 18.3451 2.32061C19.2252 2.39252 19.9523 2.54138 20.6104 2.87671C21.6924 3.42798 22.572 4.30762 23.1233 5.38955C23.4586 6.04769 23.6075 6.77479 23.6794 7.65494C23.75 8.51924 23.75 9.59466 23.75 10.9663V11.0336C23.75 12.4052 23.75 13.4808 23.6794 14.3451C23.6075 15.2252 23.4586 15.9523 23.1233 16.6104C22.572 17.6924 21.6924 18.572 20.6104 19.1233C19.9523 19.4586 19.2252 19.6075 18.3451 19.6794C17.4808 19.75 16.4053 19.75 15.0337 19.75H12.75V21.584C13.3744 21.6276 13.9959 21.7259 14.6073 21.8787L16.1819 22.2724C16.5837 22.3729 16.8281 22.7801 16.7276 23.1819C16.6271 23.5837 16.2199 23.8281 15.8181 23.7276L14.2435 23.3339C13.507 23.1498 12.7535 23.0578 12 23.0578C11.2465 23.0578 10.493 23.1498 9.75655 23.3339L8.1819 23.7276C7.78006 23.8281 7.37285 23.5837 7.27239 23.1819C7.17193 22.7801 7.41625 22.3729 7.8181 22.2724L9.39274 21.8787C10.0041 21.7259 10.6256 21.6276 11.25 21.584V19.75H8.96637C7.59476 19.75 6.51924 19.75 5.65494 19.6794C4.77479 19.6075 4.04769 19.4586 3.38955 19.1233C2.30762 18.572 1.42798 17.6924 0.876713 16.6104C0.541379 15.9523 0.392522 15.2252 0.320612 14.3451C0.249992 13.4807 0.249996 12.4053 0.25 11.0336V10.9664C0.249996 9.59472 0.249992 8.51929 0.320612 7.65494C0.392522 6.7748 0.541379 6.04769 0.876713 5.38955C1.42798 4.30762 2.30762 3.42798 3.38955 2.87671C4.04769 2.54138 4.77479 2.39252 5.65494 2.32061C6.51929 2.24999 7.59472 2.25 8.96644 2.25H15.0336ZM11.25 16C11.25 16.4142 11.5858 16.75 12 16.75L18 16.75C18.4142 16.75 18.75 16.4142 18.75 16C18.75 15.5858 18.4142 15.25 18 15.25L12 15.25C11.5858 15.25 11.25 15.5858 11.25 16ZM8 16C8 16.5523 7.55229 17 7 17C6.44772 17 6 16.5523 6 16C6 15.4477 6.44772 15 7 15C7.55229 15 8 15.4477 8 16Z"
fill="currentColor"
/>
</svg>
);
},
check(props) { check(props) {
return ( return (
<svg <svg

View File

@ -1,40 +1,46 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { cn } from "@/lib/utils";
DropdownMenu, import { Icons } from "./icons";
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() { const themeIcons = {
const { setTheme } = useTheme(); light: Icons.sun,
dark: Icons.moon,
system: Icons.display,
};
export function ModeToggle({ className }: { className?: string }) {
const { setTheme, theme, themes } = useTheme();
const currentThemeIndex = theme ? themes.indexOf(theme) : 0;
return ( return (
<DropdownMenu> <Button
<DropdownMenuTrigger asChild> variant="outline"
<Button variant="outline" size="icon"> className={cn(
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> "px-0 py-0 rounded-full size-max w-max justify-start bg-background/50 gap-1",
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> className
<span className="sr-only">Toggle theme</span> )}
</Button> onClick={() =>
</DropdownMenuTrigger> setTheme(themes[(currentThemeIndex + 1) % themes.length] as any)
<DropdownMenuContent align="end"> }
<DropdownMenuItem onClick={() => setTheme("light")}> >
Light {themes.map((selectTheme) => {
</DropdownMenuItem> const Icon = themeIcons[selectTheme as keyof typeof themeIcons];
<DropdownMenuItem onClick={() => setTheme("dark")}> return (
Dark <div
</DropdownMenuItem> key={selectTheme}
<DropdownMenuItem onClick={() => setTheme("system")}> className={cn(
System "size-5 rounded-full flex items-center justify-center",
</DropdownMenuItem> selectTheme === theme && "bg-foreground text-background"
</DropdownMenuContent> )}
</DropdownMenu> >
<Icon className="size-3.5 " />
</div>
);
})}
<span className="sr-only">Toggle theme</span>
</Button>
); );
} }

View File

@ -1,9 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { expenses, expenseSplits, type ExpenseSplit } from "@/server/db/schema"; import {
expenses,
expenseSplits,
friendships,
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"; import { and, eq, inArray, not, notInArray, or } from "drizzle-orm";
function restructureExpenseSplits(expenseSplits: Array<ExpenseSplit>) { function restructureExpenseSplits(expenseSplits: Array<ExpenseSplit>) {
const expensesMap = new Map(); const expensesMap = new Map();
@ -41,6 +46,36 @@ export const expenseRouter = createTRPCRouter({
const expenses = restructureExpenseSplits(splits); const expenses = restructureExpenseSplits(splits);
return expenses; return expenses;
}), }),
searchParticipants: protectedProcedure
.input(z.object({ search: z.string(), excludedIds: z.array(z.string()) }))
.query(async ({ ctx, input }) => {
const userId = ctx.auth.userId;
const friendResult = await ctx.db.query.friendships.findMany({
where: and(
or(
eq(friendships.userOneId, userId),
eq(friendships.userTwoId, userId)
),
notInArray(friendships.userOneId, input.excludedIds),
notInArray(friendships.userTwoId, input.excludedIds)
),
with: {
userOne: true,
userTwo: true,
},
});
const friends =
friendResult?.map(({ userOne, userTwo }) =>
ctx.auth.userId === userOne.id ? userTwo : userOne
) ?? [];
return {
friends,
groups: [],
};
}),
// mutations
create: protectedProcedure create: protectedProcedure
.input( .input(
z.object({ z.object({