feat: add expenses list + filters
This commit is contained in:
parent
0fabc1edcc
commit
6740469f94
|
|
@ -1,27 +1,55 @@
|
|||
const Filter = () => (
|
||||
const Filter = ({
|
||||
className,
|
||||
active = false,
|
||||
}: {
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
}) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M5.40002 2.09998H18.6C19.7 2.09998 20.6 2.99998 20.6 4.09998V6.29998C20.6 7.09998 20.1 8.09998 19.6 8.59998L15.3 12.4C14.7 12.9 14.3 13.9 14.3 14.7V19C14.3 19.6 13.9 20.4 13.4 20.7L12 21.6C10.7 22.4 8.90002 21.5 8.90002 19.9V14.6C8.90002 13.9 8.50002 13 8.10002 12.5L4.30002 8.49998C3.80002 7.99998 3.40002 7.09998 3.40002 6.49998V4.19998C3.40002 2.99998 4.30002 2.09998 5.40002 2.09998Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.93 2.09998L6 9.99998"
|
||||
stroke="#292D32"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{active ? (
|
||||
<>
|
||||
<path
|
||||
d="M20.7206 18.24L19.7806 17.3C20.2706 16.56 20.5606 15.67 20.5606 14.71C20.5606 12.11 18.4506 10 15.8506 10C13.2506 10 11.1406 12.11 11.1406 14.71C11.1406 17.31 13.2506 19.42 15.8506 19.42C16.8106 19.42 17.6906 19.13 18.4406 18.64L19.3806 19.58C19.5706 19.77 19.8106 19.86 20.0606 19.86C20.3106 19.86 20.5506 19.77 20.7406 19.58C21.0906 19.22 21.0906 18.62 20.7206 18.24Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19.5799 4.02V6.24C19.5799 7.05 19.0799 8.06 18.5799 8.57L18.3999 8.73C18.2599 8.86 18.0499 8.89 17.8699 8.83C17.6699 8.76 17.4699 8.71 17.2699 8.66C16.8299 8.55 16.3599 8.5 15.8799 8.5C12.4299 8.5 9.62992 11.3 9.62992 14.75C9.62992 15.89 9.93992 17.01 10.5299 17.97C11.0299 18.81 11.7299 19.51 12.4899 19.98C12.7199 20.13 12.8099 20.45 12.6099 20.63C12.5399 20.69 12.4699 20.74 12.3999 20.79L10.9999 21.7C9.69992 22.51 7.90992 21.6 7.90992 19.98V14.63C7.90992 13.92 7.50992 13.01 7.10992 12.51L3.31992 8.47C2.81992 7.96 2.41992 7.05 2.41992 6.45V4.12C2.41992 2.91 3.31992 2 4.40992 2H17.5899C18.6799 2 19.5799 2.91 19.5799 4.02Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d="M14.3201 19.07C14.3201 19.68 13.92 20.48 13.41 20.79L12.0001 21.7C10.6901 22.51 8.87006 21.6 8.87006 19.98V14.63C8.87006 13.92 8.47006 13.01 8.06006 12.51L4.22003 8.47C3.71003 7.96 3.31006 7.06001 3.31006 6.45001V4.13C3.31006 2.92 4.22008 2.01001 5.33008 2.01001H18.67C19.78 2.01001 20.6901 2.92 20.6901 4.03V6.25C20.6901 7.06 20.1801 8.07001 19.6801 8.57001"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.07 16.52C17.8373 16.52 19.27 15.0873 19.27 13.32C19.27 11.5527 17.8373 10.12 16.07 10.12C14.3027 10.12 12.87 11.5527 12.87 13.32C12.87 15.0873 14.3027 16.52 16.07 16.52Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.87 17.12L18.87 16.12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export default function ExpensesRoute() {
|
|||
return (
|
||||
<>
|
||||
<Header user={data.user} route="/expenses" />
|
||||
<main>
|
||||
<main className="container mx-auto px-2">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -148,11 +148,14 @@ export default function ExpensesIndexRoute() {
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`stat-value ${
|
||||
className={`stat-value text-2xl ${
|
||||
user.dueAmount > 0 ? "text-error" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{Math.abs(user.dueAmount)} €
|
||||
{user.dueAmount !== Math.round(user.dueAmount)
|
||||
? Math.abs(user.dueAmount).toFixed(2)
|
||||
: Math.abs(user.dueAmount)}{" "}
|
||||
€
|
||||
</div>
|
||||
<div className="stat-title text-lg">{user.username}</div>
|
||||
<div className="stat-desc text-info">
|
||||
|
|
|
|||
|
|
@ -1,33 +1,262 @@
|
|||
import { Link } from "remix";
|
||||
import type { User, Team, Expense } from "@prisma/client";
|
||||
import type { LoaderFunction } from "remix";
|
||||
import { redirect, useLoaderData, useCatch, Link, Form } from "remix";
|
||||
import Filter from "~/icons/Filter";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { getUser, requireUserId } from "~/utils/session.server";
|
||||
|
||||
type LoaderData = {
|
||||
user: (User & { team: Team & { members: User[] } }) | null;
|
||||
expenses: (Expense & { user: User & { team: Team } })[];
|
||||
expensesCount: number;
|
||||
page: number;
|
||||
filters: {
|
||||
description: string | null | undefined;
|
||||
dateFrom: string | null | undefined;
|
||||
dateTo: string | null | undefined;
|
||||
user: string | null | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const userId = requireUserId(request);
|
||||
const user = await getUser(request);
|
||||
if (!user?.id || !userId) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const expensesCount = await db.expense.count({
|
||||
where: { teamId: user.teamId },
|
||||
});
|
||||
|
||||
const searchParams = new URL(request.url)?.searchParams;
|
||||
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||
const description = searchParams.get("description");
|
||||
const dateFrom = searchParams.get("dateFrom");
|
||||
const dateTo = searchParams.get("dateTo");
|
||||
const userIdParam = searchParams.get("user");
|
||||
|
||||
const filters = {
|
||||
description:
|
||||
description && description.length > 0 ? description : undefined,
|
||||
dateFrom: dateFrom && dateFrom.length > 0 ? dateFrom : undefined,
|
||||
dateTo: dateTo && dateTo.length > 0 ? dateTo : undefined,
|
||||
user: userIdParam && userIdParam?.length > 0 ? userIdParam : undefined,
|
||||
};
|
||||
const expensesFilters = {
|
||||
...(filters.description && {
|
||||
description: { contains: filters.description },
|
||||
}),
|
||||
...((filters.dateFrom || filters.dateTo) && {
|
||||
createdAt: {
|
||||
...(filters.dateFrom && {
|
||||
gte: new Date(`${filters.dateFrom}T00:00:00+0100`),
|
||||
}),
|
||||
...(filters.dateTo && {
|
||||
lte: new Date(`${filters.dateTo}T00:00:00+0100`),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
...(filters.user && { userId: filters.user }),
|
||||
};
|
||||
console.log("FILTERS", filters);
|
||||
|
||||
const expenses = await db.expense.findMany({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
...expensesFilters,
|
||||
},
|
||||
take: 10,
|
||||
skip: (page - 1) * 10,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data: LoaderData = {
|
||||
user,
|
||||
expenses,
|
||||
expensesCount,
|
||||
page,
|
||||
filters,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export default function ListExpensesRoute() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
|
||||
const hasFilters = Object.values(data.filters).some(
|
||||
(value) => value !== undefined && value !== null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="hero py-40 bg-base-200 my-8 rounded-box">
|
||||
<div className="text-center hero-content">
|
||||
<div className="max-w-md">
|
||||
<h1 className="mb-5 text-5xl font-bold">Work in progress</h1>
|
||||
<p className="mb-5">
|
||||
<button className="btn btn-lg loading"></button>
|
||||
This page is under construction.
|
||||
</p>
|
||||
<Link to="/expenses" className="btn btn-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-6 h-6 mr-2 stroke-current rotate-180"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
></path>
|
||||
</svg>
|
||||
Back
|
||||
</Link>
|
||||
<>
|
||||
<h1 className="mb-6 mt-6 text-4xl font-bold">List expenses</h1>
|
||||
|
||||
<label htmlFor="filters-modal" className="btn modal-button">
|
||||
<Filter
|
||||
className={`w-6 h-6 mr-2${hasFilters ? " text-primary" : ""}`}
|
||||
active={hasFilters}
|
||||
/>
|
||||
Filters
|
||||
</label>
|
||||
|
||||
<input type="checkbox" id="filters-modal" className="modal-toggle" />
|
||||
<div className="modal">
|
||||
<div className="modal-box">
|
||||
<h2 className="font-bold text-lg">Filters</h2>
|
||||
<Form className="my-4">
|
||||
<div className="form-control w-full max-w-xs mb-4">
|
||||
<label className="label" htmlFor="filter-description">
|
||||
<span className="label-text">Text</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="filter-description"
|
||||
name="description"
|
||||
placeholder="Search by description"
|
||||
className="input input-bordered w-full max-w-xs"
|
||||
defaultValue={data.filters.description ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full max-w-xs mb-4">
|
||||
<label className="label" htmlFor="filter-dateFrom">
|
||||
<span className="label-text">Date from</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter-dateFrom"
|
||||
name="dateFrom"
|
||||
placeholder={new Intl.DateTimeFormat("it", {
|
||||
dateStyle: "short",
|
||||
}).format(new Date())}
|
||||
className="input input-bordered w-full max-w-xs"
|
||||
defaultValue={data.filters.dateFrom ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full max-w-xs mb-4">
|
||||
<label className="label" htmlFor="filter-dateTo">
|
||||
<span className="label-text">Date to</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter-dateTo"
|
||||
name="dateTo"
|
||||
placeholder={new Intl.DateTimeFormat("it", {
|
||||
dateStyle: "short",
|
||||
}).format(new Date())}
|
||||
className="input input-bordered w-full max-w-xs"
|
||||
defaultValue={data.filters.dateTo ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control w-full max-w-xs mb-4">
|
||||
<label className="label" htmlFor="filter-user">
|
||||
<span className="label-text">User</span>
|
||||
</label>
|
||||
<select
|
||||
name="user"
|
||||
id="filter-user"
|
||||
className="select select-bordered w-full max-w-xs"
|
||||
defaultValue={data.filters.user ?? ""}
|
||||
>
|
||||
<option value="">Choose an user</option>
|
||||
{data.user?.team?.members?.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-6 text-center">
|
||||
<label htmlFor="filters-modal" className="btn btn-default mr-4">
|
||||
Close
|
||||
</label>
|
||||
<Link to={`?page=${data.page}`} className="btn btn-default mr-4">
|
||||
Clear
|
||||
</Link>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto bg-base-100 shadow-xl rounded-box mt-6 mb-10">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Date</th>
|
||||
<th>User</th>
|
||||
<th>Amount</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.expenses?.map((exp) => (
|
||||
<tr key={exp.id}>
|
||||
<td className="sticky left-0 z-10 shadow-lg">
|
||||
<Link
|
||||
to={`/expenses/${exp.id}`}
|
||||
className="btn btn-sm btn-primary"
|
||||
>
|
||||
See
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{new Intl.DateTimeFormat("it", {
|
||||
dateStyle: "short",
|
||||
}).format(new Date(exp.createdAt))}
|
||||
</td>
|
||||
<td>{exp.user.username}</td>
|
||||
<td>{exp.amount} €</td>
|
||||
<td>{exp.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{data.expensesCount > 10 && (
|
||||
<div className="btn-group justify-center my-8">
|
||||
{[...new Array(Math.ceil(data.expensesCount / 10)).keys()].map(
|
||||
(p) => (
|
||||
<Link
|
||||
to={`?page=${p + 1}`}
|
||||
key={p}
|
||||
className={`btn${data.page === p + 1 ? " btn-active" : ""}`}
|
||||
>
|
||||
{p + 1}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 401) {
|
||||
return redirect("/login");
|
||||
}
|
||||
if (caught.status === 404) {
|
||||
return <div className="error-container">There is no data to display.</div>;
|
||||
}
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <div className="error-container">I did a whoopsies.</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,8 +83,6 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||
{}
|
||||
);
|
||||
|
||||
console.log(statsByMonth);
|
||||
|
||||
const data: LoaderData = {
|
||||
user,
|
||||
thisMonth: {
|
||||
|
|
@ -116,12 +114,14 @@ export default function ListExpensesRoute() {
|
|||
|
||||
<div className="stat w-full sm:w-1/2 md:w-1/3">
|
||||
<div className="stat-title">Average per month</div>
|
||||
<div className="stat-value">{data.avg} €</div>
|
||||
<div className="stat-value">{data.avg.toFixed(2)} €</div>
|
||||
</div>
|
||||
|
||||
<div className="stat w-full sm:w-1/2 md:w-1/3">
|
||||
<div className="stat-title">This month</div>
|
||||
<div className="stat-value">{data.thisMonth.amount} €</div>
|
||||
<div className="stat-value">
|
||||
{data.thisMonth.amount.toFixed(2)} €
|
||||
</div>
|
||||
<div className="stat-desc">{data.thisMonth.count} expenses</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue