feat: add expenses list + filters

This commit is contained in:
Nicola Zambello 2022-02-22 01:06:31 +01:00
parent 0fabc1edcc
commit 6740469f94
5 changed files with 310 additions and 50 deletions

View file

@ -1,13 +1,33 @@
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}
>
{active ? (
<>
<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"
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"
@ -15,13 +35,21 @@ const Filter = () => (
strokeLinejoin="round"
/>
<path
d="M10.93 2.09998L6 9.99998"
stroke="#292D32"
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"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19.87 17.12L18.87 16.12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg>
);

View file

@ -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>
</>

View file

@ -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">

View file

@ -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"
<>
<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 ?? ""}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
Back
<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 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>;
}

View file

@ -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>