diff --git a/client/src/components/ExpenseManager.tsx b/client/src/components/ExpenseManager.tsx index e5f502e..46dcd8a 100644 --- a/client/src/components/ExpenseManager.tsx +++ b/client/src/components/ExpenseManager.tsx @@ -51,9 +51,7 @@ const ExpenseManager: React.FC = () => {
{data.getMyExpenseFolders.map((folder: any) => ( -
- -
+ ))}
diff --git a/client/src/components/FolderCard.tsx b/client/src/components/FolderCard.tsx index 3059467..d156ee2 100644 --- a/client/src/components/FolderCard.tsx +++ b/client/src/components/FolderCard.tsx @@ -1,47 +1,163 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; +import { useMutation } from "@apollo/client"; import ExpenseForm from "./ExpenseForm"; +import { DELETE_EXPENSE, UPDATE_EXPENSE } from "../graphql/expenseMutations"; +import { FaTrash, FaEdit, FaSave, FaTimes } from "react-icons/fa"; import "../../styles/expenseManager.css"; - interface Expense { - category: string; - description?: string; - amount: number; + category: string; + description?: string; + amount: number; } interface Folder { - _id: string; - title: string; - createdAt: string; - expenses: Expense[]; + _id: string; + title: string; + createdAt: string; + expenses: Expense[]; } interface FolderCardProps { - folder: Folder; + folder: Folder; } const FolderCard: React.FC = ({ folder }) => { - return ( -
-

{folder.title}

-

- Created on: {new Date(Number(folder.createdAt)).toLocaleDateString()} -

- {folder.expenses.length === 0 ? ( -

No expenses added yet.

- ) : ( - - )} - -
- ); + const [localExpenses, setLocalExpenses] = useState(folder.expenses); + + const [editingIndex, setEditingIndex] = useState(null); + const [editValues, setEditValues] = useState({ + category: "", + amount: 0, + description: "", + }); + + const [deleteExpense] = useMutation(DELETE_EXPENSE, { + onCompleted: (data) => { + setLocalExpenses(data.deleteExpenseFromFolder.expenses); + }, + onError: (error) => { + console.error("Failed to delete expense:", error.message); + }, + }); + + const [updateExpense] = useMutation(UPDATE_EXPENSE, { + onCompleted: (data) => { + setLocalExpenses(data.updateExpenseInFolder.expenses); + setEditingIndex(null); + }, + onError: (error) => { + console.error("Failed to update expense:", error.message); + }, + }); + + useEffect(() => { + setLocalExpenses(folder.expenses); + }, [folder.expenses]); + + const handleDelete = (index: number) => { + deleteExpense({ + variables: { + folderId: folder._id, + expenseIndex: index, + }, + }); + }; + + const startEditing = (index: number) => { + const expense = localExpenses[index]; + setEditingIndex(index); + setEditValues({ ...expense }); + }; + + const cancelEditing = () => { + setEditingIndex(null); + setEditValues({ category: "", amount: 0, description: "" }); + }; + + const handleUpdate = () => { + if (editingIndex === null) return; + + updateExpense({ + variables: { + folderId: folder._id, + expenseIndex: editingIndex, + category: editValues.category, + amount: editValues.amount, + description: editValues.description, + }, + }); + }; + + const totalCost = localExpenses.reduce((sum, expense) => sum + expense.amount, 0); + + return ( +
+

{folder.title}

+

+ Created on: {new Date(Number(folder.createdAt)).toLocaleDateString()} +

+ + {localExpenses.length === 0 ? ( +

No expenses added yet.

+ ) : ( +
    + {localExpenses.map((expense, idx) => ( +
  • + {editingIndex === idx ? ( + <> + + setEditValues({ ...editValues, amount: parseFloat(e.target.value) })} + /> + setEditValues({ ...editValues, description: e.target.value })} + /> + + + + ) : ( + <> + {expense.category}: ${expense.amount.toFixed(2)} + {expense.description && ` - ${expense.description}`} + + + + )} +
  • + ))} +
+ )} + +
+ Total: ${totalCost.toFixed(2)} +
+ + +
+ ); }; export default FolderCard; \ No newline at end of file diff --git a/client/src/graphql/expenseMutations.ts b/client/src/graphql/expenseMutations.ts index 5c87237..9a13de2 100644 --- a/client/src/graphql/expenseMutations.ts +++ b/client/src/graphql/expenseMutations.ts @@ -32,4 +32,46 @@ export const ADD_EXPENSE = gql ` } } } -`; \ No newline at end of file +`; + +export const UPDATE_EXPENSE = gql` + mutation UpdateExpenseInFolder( + $folderId: ID! + $expenseIndex: Int! + $category: String! + $amount: Float! + $description: String + ) { + updateExpenseInFolder( + folderId: $folderId + expenseIndex: $expenseIndex + category: $category + amount: $amount + description: $description + ) { + _id + title + createdAt + expenses { + category + description + amount + } + } + } +`; + +export const DELETE_EXPENSE = gql` + mutation DeleteExpenseFromFolder($folderId: ID!, $expenseIndex: Int!) { + deleteExpenseFromFolder(folderId: $folderId, expenseIndex: $expenseIndex) { + _id + title + createdAt + expenses { + category + description + amount + } + } + } +`; diff --git a/client/src/main.tsx b/client/src/main.tsx index e2b19f0..06ee518 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -16,10 +16,10 @@ import { AuthProvider } from "./context/AuthContext"; // Setup HTTP connection to GraphQL const httpLink = createHttpLink({ // TODO: USE THIS FOR LOCAL TESTING: - // uri: "http://localhost:3001/graphql", + uri: "http://localhost:3001/graphql", // TODO: USE THIS FOR DEPLOY - uri: "https://miletracker-wokk.onrender.com/graphql", + //uri: "https://miletracker-wokk.onrender.com/graphql", }); // Attach token to headers diff --git a/client/styles/expenseManager.css b/client/styles/expenseManager.css index a6feb49..39709f3 100644 --- a/client/styles/expenseManager.css +++ b/client/styles/expenseManager.css @@ -53,15 +53,9 @@ } .folder-grid { - display: flex; - flex-wrap: wrap; + display: grid; gap: 1rem; - justify-content: space-between; -} - -.folder-wrapper { - flex: 0 1 calc(33.333% - 1rem); - display: flex; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } .folder-card { @@ -88,6 +82,22 @@ .folder-error { color: red; } + +.folder-total { + margin-top: 0.75rem; + font-size: 1.1rem; + font-weight: bold; + color: hotpink; +} + +.delete-expense-btn { + margin-left: 0.5rem; + background: none; + border: none; + color: red; + cursor: pointer; + font-size: 1rem; +} /* RESPONSIVE IMPROVEMENTS */ @media (max-width: 768px) { diff --git a/server/src/schemas/resolvers.ts b/server/src/schemas/resolvers.ts index 3d27c14..ff69977 100644 --- a/server/src/schemas/resolvers.ts +++ b/server/src/schemas/resolvers.ts @@ -320,6 +320,55 @@ export const resolvers = { return updatedFolder; }, + + deleteExpenseFromFolder: async ( + _:any, + { folderId, expenseIndex }:any, + context: any + ) => { + if (!context.user) throw new Error("Not authenticated"); + + const folder = await ExpenseFolder.findOne({ _id: folderId, userId: context.user._id }); + + if (!folder) { + throw new Error("Folder not found"); + } + + if (expenseIndex < 0 || expenseIndex >= folder.expenses.length) { + throw new Error("Invalid expense index"); + } + + folder.expenses.splice(expenseIndex, 1); + await folder.save(); + + return folder; + }, + + updateExpenseInFolder: async ( + _: any, + { folderId, expenseIndex, category, amount, description }: any, + context: any + ) => { + if (!context.user) throw new AuthenticationError("Not authenticated"); + + const folder = await ExpenseFolder.findOne({ + _id: folderId, + userId: context.user._id, + }); + + if (!folder) throw new Error("Folder not found"); + if (expenseIndex < 0 || expenseIndex >= folder.expenses.length) { + throw new Error("Invalid expense index"); + } + + const expense = folder.expenses[expenseIndex]; + if (category !== undefined) expense.category = category; + if (amount !== undefined) expense.amount = amount; + if (description !== undefined) expense.description = description; + + await folder.save(); + return folder; + }, }, }; diff --git a/server/src/schemas/typeDefs.ts b/server/src/schemas/typeDefs.ts index 02c6dc3..c4efd6b 100644 --- a/server/src/schemas/typeDefs.ts +++ b/server/src/schemas/typeDefs.ts @@ -69,7 +69,7 @@ const typeDefs = gql` _id: ID! title: String! createdAt: String - expenses: [Expense] + expenses: [Expense!]! } type Mutation { @@ -120,6 +120,16 @@ const typeDefs = gql` amount: Float! description: String ): ExpenseFolder + + deleteExpenseFromFolder(folderId: ID!, expenseIndex: Int!): ExpenseFolder + + updateExpenseInFolder( + folderId: ID! + expenseIndex: Int! + category: String! + amount: Float! + description: String + ): ExpenseFolder } `;