feat: add income based balances
This commit is contained in:
parent
a34358bfa0
commit
60b551801a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
prisma/migrations/20220221154720_income/migration.sql
Normal file
5
prisma/migrations/20220221154720_income/migration.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "balanceByIncome" BOOLEAN DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avgIncome" INTEGER;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue