feat: add income based balances

This commit is contained in:
Nicola Zambello 2022-02-21 18:08:35 +01:00
parent a34358bfa0
commit 60b551801a
9 changed files with 236 additions and 52 deletions

View file

@ -61,6 +61,13 @@ function validateTeamId(teamId: unknown) {
}
}
function validateIncome(income: unknown) {
if (typeof income !== "number") {
console.log(income, typeof income);
return "Income must be a positive number or zero";
}
}
type ActionData = {
formError?: string;
success?: string;
@ -69,12 +76,14 @@ type ActionData = {
teamId: string | undefined;
password: string | undefined;
confirmPassword: string | undefined;
avgIncome: string | undefined;
};
fields?: {
password?: string;
confirmPassword?: string;
teamId?: string;
icon?: string;
avgIncome?: number;
};
};
@ -91,6 +100,7 @@ export const action: ActionFunction = async ({ request }) => {
const confirmPassword = form.get("confirmPassword");
const icon = form.get("icon") ?? (user ? user.username[0] : undefined);
const teamId = form.get("teamId");
const avgIncome = form.get("avgIncome");
const fields = {
icon: typeof icon === "string" ? icon : undefined,
@ -98,6 +108,12 @@ export const action: ActionFunction = async ({ request }) => {
confirmPassword:
typeof confirmPassword === "string" ? confirmPassword : undefined,
teamId: typeof teamId === "string" ? teamId : undefined,
avgIncome:
typeof avgIncome === "number"
? Math.round(avgIncome)
: typeof avgIncome === "string"
? parseInt(avgIncome, 10)
: undefined,
};
const fieldErrors = {
password: validatePassword(password),
@ -107,14 +123,19 @@ export const action: ActionFunction = async ({ request }) => {
),
icon: validateIcon(icon),
teamId: validateTeamId(teamId),
avgIncome: validateIncome(fields.avgIncome),
};
if (Object.values(fieldErrors).some(Boolean))
return badRequest({ fieldErrors, fields });
const nonEmptyFields = Object.entries(fields).reduce((acc, [key, value]) => {
if (typeof value === "string" && key !== "confirmPassword")
if (
(typeof value === "string" && key !== "confirmPassword") ||
(typeof value === "number" && key === "avgIncome")
)
return { ...acc, [key]: value ?? undefined };
else return acc;
return acc;
}, {});
const userUpdated = await updateUser({
id: user.id,
@ -299,6 +320,48 @@ export default function AccountPreferencesRoute() {
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="avgIncome-input" className="label">
<span className="label-text">Average monthly income</span>
</label>
<input
type="number"
id="avgIncome-input"
name="avgIncome"
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.avgIncome) ? " input-error" : ""
}`}
defaultValue={
actionData?.fields?.avgIncome ?? data.user?.avgIncome ?? 0
}
aria-invalid={Boolean(actionData?.fieldErrors?.avgIncome)}
aria-describedby={
actionData?.fieldErrors?.avgIncome ? "avgIncome-error" : undefined
}
/>
{actionData?.fieldErrors?.avgIncome && (
<div className="alert alert-error mt-2" role="alert">
<div className="flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 mx-2 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
></path>
</svg>
<label id="avgIncome-error">
{actionData?.fieldErrors.avgIncome}
</label>
</div>
</div>
)}
</div>
<div className="form-control mb-3">
<label htmlFor="teamId-input" className="label">
<span className="label-text">Team</span>
@ -310,7 +373,7 @@ export default function AccountPreferencesRoute() {
className={`input input-bordered${
Boolean(actionData?.fieldErrors?.teamId) ? " input-error" : ""
}`}
defaultValue={actionData?.fields?.teamId}
defaultValue={actionData?.fields?.teamId ?? data.user?.teamId}
aria-invalid={Boolean(actionData?.fieldErrors?.teamId)}
aria-describedby={
actionData?.fieldErrors?.teamId ? "teamid-error" : undefined

View file

@ -15,6 +15,7 @@ type LoaderData = {
count: number;
spent: number;
dueAmount: number;
avgIncome: number;
}[];
totalExpenses: {
count: number;
@ -52,19 +53,33 @@ export const loader: LoaderFunction = async ({ request }) => {
icon: m.icon,
count: teamExpenses.find((e) => e.userId === m.id)?._count?._all ?? 0,
spent: teamExpenses.find((e) => e.userId === m.id)?._sum?.amount ?? 0,
avgIncome: m.avgIncome ?? 0,
dueAmount: 0,
}));
let totalExpenses = expensesByUser.reduce(
(acc, { count, spent }) => ({
(acc, { count, spent, avgIncome }) => ({
count: acc.count + count,
amount: acc.amount + spent,
incomes: acc.incomes + avgIncome,
}),
{ count: 0, amount: 0 }
{ count: 0, amount: 0, incomes: 0 }
);
const avgPerUser = totalExpenses.amount / user.team.members.length;
const quotaPerUser = expensesByUser.reduce(
(acc: { [key: string]: number }, userData) => {
acc[userData.id] =
(userData.avgIncome / totalExpenses.incomes) * totalExpenses.amount;
return acc;
},
{}
);
let teamCounts = expensesByUser.map((userData) => ({
...userData,
dueAmount: avgPerUser - userData.spent,
dueAmount: user.team.balanceByIncome
? quotaPerUser[userData.id] - userData.spent
: avgPerUser - userData.spent,
}));
const data: LoaderData = {
@ -123,7 +138,7 @@ export default function ExpensesIndexRoute() {
</div>
<div className="col-span-2 md:col-span-1 card shadow-lg compact side bg-base-100">
<div className="flex-column items-center card-body !py-6">
<h2 className="card-title">Who needs to pay who</h2>
<h2 className="card-title">Team balance</h2>
<div className="w-full shadow stats grid-cols-2 grid-flow-row-dense">
{data.teamCounts?.map((user) => (
<div className="stat col-span-1" key={user.id}>

View file

@ -34,7 +34,7 @@ function validateConfirmPassword(confirmPassword: unknown, password: string) {
}
function validateIcon(icon: unknown) {
if (typeof icon !== "string" || icon.length > 2) {
if (typeof icon !== "string") {
return `Icons must be a single character, e.g. "A" or "😎"`;
}
}
@ -70,9 +70,7 @@ export const action: ActionFunction = async ({ request }) => {
const username = form.get("username");
const password = form.get("password");
const confirmPassword = form.get("confirmPassword");
const icon =
form.get("icon") ??
(typeof username === "string" ? username[0] : undefined);
const icon = form.get("icon");
const teamId = form.get("teamId");
const redirectTo = form.get("redirectTo") || "/expenses";
if (
@ -80,6 +78,7 @@ export const action: ActionFunction = async ({ request }) => {
typeof password !== "string" ||
typeof confirmPassword !== "string" ||
typeof teamId !== "string" ||
typeof icon !== "string" ||
typeof redirectTo !== "string"
) {
return badRequest({
@ -87,12 +86,12 @@ export const action: ActionFunction = async ({ request }) => {
});
}
const fields = { username, password, confirmPassword, teamId };
const fields = { username, password, icon, confirmPassword, teamId };
const fieldErrors = {
username: validateUsername(username),
password: validatePassword(password),
confirmPassword: validateConfirmPassword(confirmPassword, password),
icon: validateIcon(icon),
icon: validateIcon(icon ?? ""),
teamId: validateTeamId(teamId),
};
if (Object.values(fieldErrors).some(Boolean))

View file

@ -1,8 +1,9 @@
import type { LoaderFunction } from "remix";
import { useLoaderData, useCatch, redirect } from "remix";
import type { LoaderFunction, ActionFunction } from "remix";
import { useLoaderData, useCatch, redirect, Link, Form } from "remix";
import type { Team, User } from "@prisma/client";
import { getUser } from "~/utils/session.server";
import Header from "~/components/Header";
import { db } from "~/utils/db.server";
import { requireUserId, getUser } from "~/utils/session.server";
type LoaderData = {
user: User & {
@ -21,9 +22,43 @@ export const loader: LoaderFunction = async ({ request }) => {
return data;
};
export const action: ActionFunction = async ({ request }) => {
const form = await request.formData();
if (form.get("_method") === "patch") {
const userId = await requireUserId(request);
const user = await getUser(request);
const balanceByIncomeField = form.get("balanceByIncome");
const balanceByIncome =
typeof balanceByIncomeField === "boolean"
? balanceByIncomeField
: typeof balanceByIncomeField === "string"
? Boolean(balanceByIncomeField)
: false;
const team = await db.team.findUnique({
where: { id: user?.teamId },
});
if (!team) {
throw new Response("Can't update what does not exist", { status: 404 });
}
if (user?.teamId !== team.id) {
throw new Response("Pssh, nice try. That's not your expense", {
status: 401,
});
}
await db.team.update({ where: { id: team.id }, data: { balanceByIncome } });
return redirect("/expenses");
}
};
export default function JokesIndexRoute() {
const data = useLoaderData<LoaderData>();
const allUsersHaveSetAvgIncome = data.user.team.members?.every(
(m) => m.avgIncome && m.avgIncome > 0
);
return (
<>
<Header user={data.user} route="/account" />
@ -52,6 +87,64 @@ export default function JokesIndexRoute() {
</div>
))
)}
<div className="mt-10">
<h2 className="mb-4 text-xl">Balance based on income</h2>
<p>
To have an equal split based on everyone's income, you can select
this option. If of two people, one earns 1.5 times as much as the
other, then his or her share in the common expenses will be 1.5
times as much as the other's.
</p>
<Form method="post">
<input type="hidden" name="_method" value="patch" />
<fieldset disabled={!allUsersHaveSetAvgIncome}>
<div className="form-control mt-4">
<label className="cursor-pointer label justify-start">
<input
type="checkbox"
name="balanceByIncome"
defaultChecked={data.user.team.balanceByIncome ?? false}
className="checkbox checkbox-primary"
/>
<span className="label-text ml-2">
Enable balance based on income
</span>
</label>
</div>
</fieldset>
{!allUsersHaveSetAvgIncome ? (
<div className="alert shadow-lg">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="stroke-info-content flex-shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>
Not all users from this team set their average income.
</span>
</div>
<div className="flex-none">
<Link to="/account/manage" className="btn btn-sm">
Check in the Account page
</Link>
</div>
</div>
) : (
<button type="submit" className="mt-6 btn btn-primary">
Save
</button>
)}
</Form>
</div>
</main>
</>
);

View file

@ -10,7 +10,7 @@ type LoginForm = {
type RegisterForm = {
username: string;
password: string;
icon: string;
icon?: string;
teamId: string;
};
@ -19,6 +19,7 @@ type UpdateUserForm = {
password?: string;
icon?: string;
teamId?: string;
avgIncome?: number;
};
export async function register({
@ -38,7 +39,12 @@ export async function register({
});
}
const user = await db.user.create({
data: { username, passwordHash, icon: icon ?? username[0], teamId },
data: {
username,
passwordHash,
icon: icon && icon.length > 0 ? icon : username[0],
teamId,
},
});
return user;
}
@ -62,6 +68,7 @@ export async function updateUser({ id, ...data }: UpdateUserForm) {
...(data?.icon ? { icon: data.icon } : {}),
...(data?.password ? { passwordHash } : {}),
...(data?.teamId ? { teamId: data.teamId } : {}),
...(data?.avgIncome ? { avgIncome: data.avgIncome } : {}),
},
where: { id },
});

View file

@ -8,9 +8,9 @@
"scripts": {
"build": "npm run build:css && npm run build:worker && remix build",
"build:css": "tailwindcss -o ./app/tailwind.css",
"build:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
"build:worker": "esbuild ./app/entry.worker.tsx --outfile=./build/entry.worker.js --minify --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
"dev": "concurrently \"npm run dev:css\" \"npm run dev:worker\" \"remix dev\"",
"dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:worker": "esbuild ./app/entry.worker.tsx --outfile=./build/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:css": "tailwindcss -o ./app/tailwind.css --watch",
"postinstall": "remix setup node",
"prepare": "husky install",

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "balanceByIncome" BOOLEAN DEFAULT false;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avgIncome" INTEGER;

View file

@ -11,10 +11,11 @@ datasource db {
}
model Team {
id String @id
icon String
description String?
members User[]
id String @id
icon String
description String?
members User[]
balanceByIncome Boolean? @default(value: false)
}
model User {
@ -28,6 +29,7 @@ model User {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
expenses Expense[]
theme String?
avgIncome Int?
}
model Expense {

View file

@ -2,29 +2,29 @@ import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
async function seed() {
const famiglia = await db.team.create({
const team = await db.team.create({
data: {
id: "Famiglia",
description: "La mia famiglia",
id: "Family",
description: "My family",
icon: "♥️",
},
});
const nicola = await db.user.create({
const user1 = await db.user.create({
data: {
username: "nicola",
username: "user1",
passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
teamId: famiglia.id,
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u", // twixrox
teamId: team.id,
icon: "🧑‍💻",
theme: "dark",
},
});
const shahra = await db.user.create({
const user2 = await db.user.create({
data: {
username: "shahra",
username: "user2",
passwordHash:
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
teamId: famiglia.id,
teamId: team.id,
icon: "💃",
theme: "emerald",
},
@ -32,43 +32,43 @@ async function seed() {
const expenses = [
{
description: "Spesa",
description: "Groceries",
amount: 100,
userId: nicola.id,
teamId: famiglia.id,
userId: user1.id,
teamId: team.id,
},
{
description: "Spesa",
description: "Groceries",
amount: 70,
userId: shahra.id,
teamId: famiglia.id,
userId: user2.id,
teamId: team.id,
},
{
description: "Affitto",
description: "Rent",
amount: 500,
userId: shahra.id,
teamId: famiglia.id,
userId: user2.id,
teamId: team.id,
},
// transaction between users
{
description: "Affitto",
description: "Rent",
amount: 250,
userId: nicola.id,
teamId: famiglia.id,
userId: user1.id,
teamId: team.id,
},
{
description: "Affitto",
description: "Rent",
amount: -250,
userId: shahra.id,
teamId: famiglia.id,
userId: user2.id,
teamId: team.id,
},
{
description: "Cena",
description: "Dinner out",
amount: 50,
userId: nicola.id,
teamId: famiglia.id,
userId: user1.id,
teamId: team.id,
},
];