diff --git a/app/routes/account/manage.tsx b/app/routes/account/manage.tsx index 75f9439..9a35ffd 100644 --- a/app/routes/account/manage.tsx +++ b/app/routes/account/manage.tsx @@ -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() { )} +
+ + + {actionData?.fieldErrors?.avgIncome && ( +
+
+ + + + +
+
+ )} +
-

Who needs to pay who

+

Team balance

{data.teamCounts?.map((user) => (
diff --git a/app/routes/signin.tsx b/app/routes/signin.tsx index 6a7b64a..e070c05 100644 --- a/app/routes/signin.tsx +++ b/app/routes/signin.tsx @@ -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)) diff --git a/app/routes/team.tsx b/app/routes/team.tsx index b2847c3..441a0dc 100644 --- a/app/routes/team.tsx +++ b/app/routes/team.tsx @@ -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(); + const allUsersHaveSetAvgIncome = data.user.team.members?.every( + (m) => m.avgIncome && m.avgIncome > 0 + ); + return ( <>
@@ -52,6 +87,64 @@ export default function JokesIndexRoute() {
)) )} +
+

Balance based on income

+

+ 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. +

+
+ +
+
+ +
+
+ {!allUsersHaveSetAvgIncome ? ( +
+
+ + + + + Not all users from this team set their average income. + +
+
+ + Check in the Account page + +
+
+ ) : ( + + )} +
+
); diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts index 9bcc7d1..6cd7f88 100644 --- a/app/utils/session.server.ts +++ b/app/utils/session.server.ts @@ -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 }, }); diff --git a/package.json b/package.json index 6ffa91d..0ba2d09 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20220221154720_income/migration.sql b/prisma/migrations/20220221154720_income/migration.sql new file mode 100644 index 0000000..6b7b01c --- /dev/null +++ b/prisma/migrations/20220221154720_income/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "balanceByIncome" BOOLEAN DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avgIncome" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dc54bd2..803ffd6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/prisma/seed.ts b/prisma/seed.ts index cd3e8f6..b76d2e5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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, }, ];