From eaeb85d965a55811de80cb6a751a6dcbb4721910 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Thu, 21 May 2026 21:58:06 +0300 Subject: [PATCH] refactor: split large controllers and components into focused modules Backend: transactionController (806 lines) split into transaction/ subfolder with crud, stats and helpers files. walletController (359 lines) split into wallet/ subfolder with query and mutation handlers. Routers updated. Frontend: extracted TransactionFormFields shared between Add and Change popups, TransactionRow and skeleton from WatchTransactionsPopup, moved extractApiErrorMessage to utils/functions.ts. --- .../transactionCrudHandlers.ts} | 581 +++--------------- .../transaction/transactionHelpers.ts | 22 + .../transaction/transactionStatsHandlers.ts | 359 +++++++++++ .../wallet/walletMutationHandlers.ts | 152 +++++ .../walletQueryHandlers.ts} | 155 +---- apps/backend/src/router/transactionRouter.ts | 4 +- apps/backend/src/router/walletRouter.ts | 6 +- .../Transactions/AddTransactionPopup.tsx | 141 ++--- .../Transactions/ChangeTransactionPopup.tsx | 107 +--- .../Transactions/TransactionFormFields.tsx | 109 ++++ .../modules/Transactions/TransactionRow.tsx | 92 +++ .../Transactions/WatchTransactionsPopup.tsx | 93 +-- apps/frontend/src/utils/functions.ts | 20 + 13 files changed, 928 insertions(+), 913 deletions(-) rename apps/backend/src/controllers/{transactionController.ts => transaction/transactionCrudHandlers.ts} (57%) create mode 100644 apps/backend/src/controllers/transaction/transactionHelpers.ts create mode 100644 apps/backend/src/controllers/transaction/transactionStatsHandlers.ts create mode 100644 apps/backend/src/controllers/wallet/walletMutationHandlers.ts rename apps/backend/src/controllers/{walletController.ts => wallet/walletQueryHandlers.ts} (64%) create mode 100644 apps/frontend/src/modules/Transactions/TransactionFormFields.tsx create mode 100644 apps/frontend/src/modules/Transactions/TransactionRow.tsx diff --git a/apps/backend/src/controllers/transactionController.ts b/apps/backend/src/controllers/transaction/transactionCrudHandlers.ts similarity index 57% rename from apps/backend/src/controllers/transactionController.ts rename to apps/backend/src/controllers/transaction/transactionCrudHandlers.ts index 6115140..1e2b601 100644 --- a/apps/backend/src/controllers/transactionController.ts +++ b/apps/backend/src/controllers/transaction/transactionCrudHandlers.ts @@ -1,48 +1,19 @@ -import type { Prisma } from "@prisma/client"; import type { Request, Response } from "express"; import crypto from "node:crypto"; import { z } from "zod"; -import prisma from "../prisma.js"; +import prisma from "../../prisma.js"; import { TransactionResponseSchema, CreateTransactionDto, PaginatedTransactionsSchema, -} from "../models/TransactionSchema.js"; +} from "../../models/TransactionSchema.js"; +import { getCoinBalance, handleZodError } from "../../utils/helpers.js"; import { - getCoinBalance, - getStartDate, - handleZodError, -} from "../utils/helpers.js"; + formatTransaction, + type TransactionRow, +} from "./transactionHelpers.js"; const TransactionsArraySchema = z.array(TransactionResponseSchema); -import { - CoinInfoSchema, - CoinForChartSchema, -} from "../models/CoinInfoSchema.js"; - -type TransactionPayload = Prisma.TransactionGetPayload<{}>; -type TransactionRow = { - id: string; - walletId: string; - coinSymbol: string; - swapGroupId: string | null; - buyOrSell: "buy" | "sell"; - price: number | string; - quantity: number | string; - createdAt: Date; - updatedAt: Date; -}; - -const formatTransaction = (tx: TransactionPayload | TransactionRow) => { - return { - ...tx, - price: Number(tx.price), - quantity: Number(tx.quantity), - total: Number(tx.price) * Number(tx.quantity), - }; -}; - -// ====================================================================== export const getTransactions = async (req: Request, res: Response) => { try { @@ -87,104 +58,6 @@ export const getTransactions = async (req: Request, res: Response) => { // ====================================================================== -export const createTransaction = async (req: Request, res: Response) => { - try { - const { walletId } = req.params; - if (!walletId) { - return res.status(400).json({ error: "Wallet ID is required." }); - } - - const validationResult = CreateTransactionDto.safeParse({ - ...req.body, - walletId, - }); - - if (!validationResult.success) { - return handleZodError(res, validationResult.error); - } - - const { coinSymbol, buyOrSell, price, quantity, createdAt } = - validationResult.data; - const txDate = createdAt ?? new Date(); - - if (buyOrSell === "sell") { - const balanceAtTime = await getCoinBalance(walletId, coinSymbol, txDate); - - if (balanceAtTime < quantity) { - return res.status(400).json({ - error: `Insufficient funds. Available balance was ${balanceAtTime} ${coinSymbol.toUpperCase()} up to transaction time (${txDate.toLocaleString()}), but tried to sell ${quantity}.`, - }); - } - } - - const [newTransaction] = await prisma.$queryRaw` - INSERT INTO "Transaction" ( - "id", - "walletId", - "coinSymbol", - "swapGroupId", - "buyOrSell", - "price", - "quantity", - "createdAt", - "updatedAt" - ) - VALUES ( - ${crypto.randomUUID()}, - ${walletId}, - ${coinSymbol}, - NULL, - ${buyOrSell}::"BuyOrSell", - ${price}::numeric, - ${quantity}::numeric, - ${txDate}, - NOW() - ) - RETURNING - "id", - "walletId", - "coinSymbol", - "swapGroupId", - "buyOrSell", - "price", - "quantity", - "createdAt", - "updatedAt"; - `; - - // const newTransaction = await prisma.transaction.create({ - // data: { - // walletId, - // coinSymbol, - // buyOrSell, - // price, - // quantity, - // createdAt, - // }, - // }); - - if (!newTransaction) { - return res.status(500).json({ error: "Failed to create transaction." }); - } - - const formatted = formatTransaction(newTransaction); - const response = TransactionResponseSchema.parse(formatted); - - return res.status(201).json(response); - } catch (error: any) { - if (error.code === "P2003") { - return res.status(404).json({ error: "Wallet not found." }); - } - if (error instanceof z.ZodError) return handleZodError(res, error); - - console.error("Error creating transaction:", error); - return res.status(500).json({ - error: "Internal Server Error", - }); - } -}; - -// ====================================================================== export const getPaginatedTransactions = async (req: Request, res: Response) => { try { const { walletId } = req.params; @@ -291,7 +164,6 @@ export const getTransaction = async (req: Request, res: Response) => { } const formatted = formatTransaction(transaction); - const validatedResponse = TransactionResponseSchema.parse(formatted); return res.status(200).json(validatedResponse); @@ -303,6 +175,102 @@ export const getTransaction = async (req: Request, res: Response) => { // ====================================================================== +export const createTransaction = async (req: Request, res: Response) => { + try { + const { walletId } = req.params; + if (!walletId) { + return res.status(400).json({ error: "Wallet ID is required." }); + } + + const validationResult = CreateTransactionDto.safeParse({ + ...req.body, + walletId, + }); + + if (!validationResult.success) { + return handleZodError(res, validationResult.error); + } + + const { coinSymbol, buyOrSell, price, quantity, createdAt } = + validationResult.data; + const txDate = createdAt ?? new Date(); + + if (buyOrSell === "sell") { + const balanceAtTime = await getCoinBalance(walletId, coinSymbol, txDate); + + if (balanceAtTime < quantity) { + return res.status(400).json({ + error: `Insufficient funds. Available balance was ${balanceAtTime} ${coinSymbol.toUpperCase()} up to transaction time (${txDate.toLocaleString()}), but tried to sell ${quantity}.`, + }); + } + } + + const [newTransaction] = await prisma.$queryRaw` + INSERT INTO "Transaction" ( + "id", + "walletId", + "coinSymbol", + "swapGroupId", + "buyOrSell", + "price", + "quantity", + "createdAt", + "updatedAt" + ) + VALUES ( + ${crypto.randomUUID()}, + ${walletId}, + ${coinSymbol}, + NULL, + ${buyOrSell}::"BuyOrSell", + ${price}::numeric, + ${quantity}::numeric, + ${txDate}, + NOW() + ) + RETURNING + "id", + "walletId", + "coinSymbol", + "swapGroupId", + "buyOrSell", + "price", + "quantity", + "createdAt", + "updatedAt"; + `; + + // const newTransaction = await prisma.transaction.create({ + // data: { + // walletId, + // coinSymbol, + // buyOrSell, + // price, + // quantity, + // createdAt, + // }, + // }); + + if (!newTransaction) { + return res.status(500).json({ error: "Failed to create transaction." }); + } + + const formatted = formatTransaction(newTransaction); + const response = TransactionResponseSchema.parse(formatted); + + return res.status(201).json(response); + } catch (error: any) { + if (error.code === "P2003") { + return res.status(404).json({ error: "Wallet not found." }); + } + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Error creating transaction:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// ====================================================================== + export const deleteTransaction = async (req: Request, res: Response) => { try { const { transactionId, walletId } = req.params; @@ -570,348 +538,3 @@ export const updateTransaction = async (req: Request, res: Response) => { return res.status(500).json({ error: "Server error" }); } }; - -// ====================================================================== - -export const getAllTransactionsGroupByCoinSymbol = async ( - req: Request, - res: Response, -) => { - try { - const { walletId } = req.params; - if (!walletId) { - return res.status(400).json({ error: "Wallet ID is required." }); - } - - const portfolio = await prisma.$queryRaw< - { - coinSymbol: string; - totalQuantity: number | string; - avgBuyingPrice: number | string; - }[] - >` - SELECT - t."coinSymbol", - ROUND( - SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE -t."quantity" END)::numeric, - 8 - ) AS "totalQuantity", - COALESCE( - ROUND( - ( - SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."price" * t."quantity" ELSE 0 END) - / NULLIF(SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE 0 END), 0) - )::numeric, - 2 - ), - 0 - ) AS "avgBuyingPrice" - FROM "Transaction" t - INNER JOIN "Wallet" w ON w."id" = t."walletId" - INNER JOIN "User" u ON u."id" = w."userId" - WHERE - t."walletId" = ${walletId} - AND (u."id" = w."userId") - AND t."coinSymbol" LIKE '%' - GROUP BY t."coinSymbol" - HAVING SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE -t."quantity" END) > 0 - ORDER BY SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE -t."quantity" END) DESC; - `; - - // const transactions = await prisma.transaction.findMany({ - // where: { walletId }, - // orderBy: [{ createdAt: "asc" }, { id: "asc" }], - // }); - - const normalizedPortfolio = portfolio.map( - (item: { - coinSymbol: string; - totalQuantity: number | string; - avgBuyingPrice: number | string; - }) => ({ - coinSymbol: item.coinSymbol, - totalQuantity: Number(item.totalQuantity), - avgBuyingPrice: Number(item.avgBuyingPrice), - }), - ); - - const validatedPortfolio = z - .array(CoinInfoSchema) - .parse(normalizedPortfolio); - - return res.status(200).json(validatedPortfolio); - } catch (error) { - if (error instanceof z.ZodError) return handleZodError(res, error); - - console.error("Error calculating portfolio:", error); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -// ====================================================================== - -export const getTransactionsByCoin = async (req: Request, res: Response) => { - try { - const { walletId, coinSymbol } = req.params; - - if (!walletId || !coinSymbol) { - return res.status(400).json({ error: "Coin symbol is required" }); - } - - const normalizedCoin = coinSymbol.toLowerCase(); - const transactions = await prisma.$queryRaw` - SELECT - t."id", - t."walletId", - t."coinSymbol", - t."swapGroupId", - t."buyOrSell", - t."price", - t."quantity", - t."createdAt", - t."updatedAt" - FROM "Transaction" t - WHERE - t."walletId" = ${walletId} - AND ( - t."coinSymbol" LIKE ${normalizedCoin} - OR t."coinSymbol" = ANY(ARRAY[${normalizedCoin}]::text[]) - ) - AND t."buyOrSell" IN ('buy'::"BuyOrSell", 'sell'::"BuyOrSell") - ORDER BY t."createdAt" DESC, t."id" DESC; - `; - - // const transactions = await prisma.transaction.findMany({ - // where: { - // walletId, - // coinSymbol: coinSymbol.toLowerCase(), - // }, - // orderBy: [{ createdAt: "desc" }, { id: "desc" }], - // }); - - const formatted = transactions.map((tx: TransactionRow) => - formatTransaction(tx), - ); - - const validatedTransactions = TransactionsArraySchema.parse(formatted); - - return res.status(200).json(validatedTransactions); - } catch (error: any) { - if (error instanceof z.ZodError) return handleZodError(res, error); - console.error("Error fetching transactions by coin:", error); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -// ====================================================================== -export const getCoinStats = async (req: Request, res: Response) => { - try { - const { walletId, coinSymbol } = req.params; - - if (!walletId || !coinSymbol) { - return res.status(400).json({ error: "Coin symbol is required" }); - } - - const normalizedCoin = coinSymbol.toLowerCase(); - - const [statsRow] = await prisma.$queryRaw< - { - coinSymbol: string; - totalQuantity: number | string; - avgBuyingPrice: number | string; - transactionsCount: bigint | number; - minPrice: number | string | null; - maxPrice: number | string | null; - }[] - >` - SELECT - ${normalizedCoin}::text AS "coinSymbol", - ROUND( - COALESCE( - SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE -"quantity" END), - 0 - )::numeric, - 8 - ) AS "totalQuantity", - COALESCE( - ROUND( - ( - SUM(CASE WHEN "buyOrSell" = 'buy' THEN "price" * "quantity" ELSE 0 END) - / NULLIF(SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE 0 END), 0) - )::numeric, - 2 - ), - 0 - ) AS "avgBuyingPrice", - COUNT(*) AS "transactionsCount", - MIN("price") AS "minPrice", - MAX("price") AS "maxPrice" - FROM "Transaction" - WHERE "walletId" = ${walletId} AND "coinSymbol" = ${normalizedCoin} - GROUP BY "coinSymbol"; - `; - - // const transactions = await prisma.transaction.findMany({ - // where: { - // walletId, - // coinSymbol: coinSymbol.toLowerCase(), - // }, - // orderBy: [{ createdAt: "asc" }, { id: "asc" }], - // }); - - const stats = { - coinSymbol: normalizedCoin, - totalQuantity: Number(statsRow?.totalQuantity ?? 0), - avgBuyingPrice: Number(statsRow?.avgBuyingPrice ?? 0), - }; - - const validatedStats = CoinInfoSchema.parse(stats); - - return res.status(200).json(validatedStats); - } catch (error) { - if (error instanceof z.ZodError) return handleZodError(res, error); - console.error("Error getting coin stats:", error); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -// ====================================================================== - -export const getGroupedTransactionsForChart = async ( - req: Request, - res: Response, -) => { - try { - const { walletId } = req.params; - let { range } = req.query as { range?: string }; - const effectiveRange = range || "7d"; // Default - - if (!walletId) { - return res.status(400).json({ error: "Wallet ID is required." }); - } - const startDate = getStartDate(effectiveRange); - - // Get initial balance (parsed Decimal quantities safely) - const initialBalanceAggregations = await prisma.$queryRaw` - SELECT - "coinSymbol", - SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE -"quantity" END) AS "initialQuantity" - FROM "Transaction" - WHERE - "walletId" = ${walletId} AND "createdAt" < ${startDate} - GROUP BY "coinSymbol" - HAVING SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE -"quantity" END) != 0; - `; - const initialBalances: Record = {}; - for (const agg of initialBalanceAggregations as any[]) { - initialBalances[agg.coinSymbol] = Number(agg.initialQuantity) || 0; - } - - // TRANSACTION in range - const transactionsInPeriod = await prisma.$queryRaw< - { - coinSymbol: string; - createdAt: Date; - quantity: number | string; - buyOrSell: string; - }[] - >` - SELECT - t."coinSymbol", - t."createdAt", - t."quantity", - t."buyOrSell" - FROM "Transaction" t - WHERE - t."walletId" = ${walletId} - AND t."createdAt" BETWEEN ${startDate} AND NOW() - AND t."coinSymbol" IN ( - SELECT DISTINCT t2."coinSymbol" - FROM "Transaction" t2 - WHERE t2."walletId" = ${walletId} - ) - AND EXISTS ( - SELECT 1 - FROM "Wallet" w - WHERE w."id" = t."walletId" AND w."id" = ${walletId} - ) - AND NOW() > ( - SELECT MIN(t3."createdAt") - FROM "Transaction" t3 - WHERE t3."walletId" = t."walletId" - ) - AND t."createdAt" < ( - SELECT MAX(t4."createdAt") + INTERVAL '100 years' - FROM "Transaction" t4 - WHERE t4."walletId" = t."walletId" - ) - ORDER BY t."createdAt" ASC, t."id" ASC; - `; - - // const transactionsInPeriod = await prisma.transaction.findMany({ - // where: { - // walletId, - // createdAt: { gte: startDate }, - // }, - // select: { - // coinSymbol: true, - // createdAt: true, - // quantity: true, - // buyOrSell: true, - // }, - // orderBy: [{ createdAt: "asc" }, { id: "asc" }], - // }); - - const groupedData: Record< - string, - { - coinSymbol: string; - initialQuantity: number; - agregatedData: { - createdAt: Date; - quantity: number; - buyOrSell: string; - }[]; - } - > = {}; - - for (const tx of transactionsInPeriod) { - const symbol = tx.coinSymbol; - - if (!groupedData[symbol]) { - groupedData[symbol] = { - coinSymbol: symbol, - agregatedData: [], - initialQuantity: initialBalances[symbol] ?? 0, - }; - } - - groupedData[symbol].agregatedData.push({ - createdAt: tx.createdAt, - quantity: Number(tx.quantity), - buyOrSell: tx.buyOrSell, - }); - } - - // Add missing symbols - sleeping coins - Object.keys(initialBalances).forEach((symbol) => { - if (!groupedData[symbol]) { - groupedData[symbol] = { - coinSymbol: symbol, - agregatedData: [], - initialQuantity: initialBalances[symbol] ?? 0, - }; - } - }); - - const finalGroupedArray = Object.values(groupedData); - const validatedResponse = z - .array(CoinForChartSchema) - .parse(finalGroupedArray); - return res.status(200).json(validatedResponse); - } catch (error) { - if (error instanceof z.ZodError) return handleZodError(res, error); - console.error("Error getting coin stats:", error); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; diff --git a/apps/backend/src/controllers/transaction/transactionHelpers.ts b/apps/backend/src/controllers/transaction/transactionHelpers.ts new file mode 100644 index 0000000..0136281 --- /dev/null +++ b/apps/backend/src/controllers/transaction/transactionHelpers.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "@prisma/client"; + +export type TransactionPayload = Prisma.TransactionGetPayload<{}>; + +export type TransactionRow = { + id: string; + walletId: string; + coinSymbol: string; + swapGroupId: string | null; + buyOrSell: "buy" | "sell"; + price: number | string; + quantity: number | string; + createdAt: Date; + updatedAt: Date; +}; + +export const formatTransaction = (tx: TransactionPayload | TransactionRow) => ({ + ...tx, + price: Number(tx.price), + quantity: Number(tx.quantity), + total: Number(tx.price) * Number(tx.quantity), +}); diff --git a/apps/backend/src/controllers/transaction/transactionStatsHandlers.ts b/apps/backend/src/controllers/transaction/transactionStatsHandlers.ts new file mode 100644 index 0000000..288697a --- /dev/null +++ b/apps/backend/src/controllers/transaction/transactionStatsHandlers.ts @@ -0,0 +1,359 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import prisma from "../../prisma.js"; +import { TransactionResponseSchema } from "../../models/TransactionSchema.js"; +import { + CoinInfoSchema, + CoinForChartSchema, +} from "../../models/CoinInfoSchema.js"; +import { getStartDate, handleZodError } from "../../utils/helpers.js"; +import { + formatTransaction, + type TransactionRow, +} from "./transactionHelpers.js"; + +const TransactionsArraySchema = z.array(TransactionResponseSchema); + +export const getAllTransactionsGroupByCoinSymbol = async ( + req: Request, + res: Response, +) => { + try { + const { walletId } = req.params; + if (!walletId) { + return res.status(400).json({ error: "Wallet ID is required." }); + } + + const portfolio = await prisma.$queryRaw< + { + coinSymbol: string; + totalQuantity: number | string; + avgBuyingPrice: number | string; + }[] + >` + SELECT + t."coinSymbol", + ROUND( + SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE -t."quantity" END)::numeric, + 8 + ) AS "totalQuantity", + COALESCE( + ROUND( + ( + SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."price" * t."quantity" ELSE 0 END) + / NULLIF(SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE 0 END), 0) + )::numeric, + 2 + ), + 0 + ) AS "avgBuyingPrice" + FROM "Transaction" t + INNER JOIN "Wallet" w ON w."id" = t."walletId" + INNER JOIN "User" u ON u."id" = w."userId" + WHERE + t."walletId" = ${walletId} + AND (u."id" = w."userId") + AND t."coinSymbol" LIKE '%' + GROUP BY t."coinSymbol" + HAVING SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE -t."quantity" END) > 0 + ORDER BY SUM(CASE WHEN t."buyOrSell" = 'buy' THEN t."quantity" ELSE -t."quantity" END) DESC; + `; + + // const transactions = await prisma.transaction.findMany({ + // where: { walletId }, + // orderBy: [{ createdAt: "asc" }, { id: "asc" }], + // }); + + const normalizedPortfolio = portfolio.map( + (item: { + coinSymbol: string; + totalQuantity: number | string; + avgBuyingPrice: number | string; + }) => ({ + coinSymbol: item.coinSymbol, + totalQuantity: Number(item.totalQuantity), + avgBuyingPrice: Number(item.avgBuyingPrice), + }), + ); + + const validatedPortfolio = z + .array(CoinInfoSchema) + .parse(normalizedPortfolio); + + return res.status(200).json(validatedPortfolio); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + + console.error("Error calculating portfolio:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// ====================================================================== + +export const getTransactionsByCoin = async (req: Request, res: Response) => { + try { + const { walletId, coinSymbol } = req.params; + + if (!walletId || !coinSymbol) { + return res.status(400).json({ error: "Coin symbol is required" }); + } + + const normalizedCoin = coinSymbol.toLowerCase(); + const transactions = await prisma.$queryRaw` + SELECT + t."id", + t."walletId", + t."coinSymbol", + t."swapGroupId", + t."buyOrSell", + t."price", + t."quantity", + t."createdAt", + t."updatedAt" + FROM "Transaction" t + WHERE + t."walletId" = ${walletId} + AND ( + t."coinSymbol" LIKE ${normalizedCoin} + OR t."coinSymbol" = ANY(ARRAY[${normalizedCoin}]::text[]) + ) + AND t."buyOrSell" IN ('buy'::"BuyOrSell", 'sell'::"BuyOrSell") + ORDER BY t."createdAt" DESC, t."id" DESC; + `; + + // const transactions = await prisma.transaction.findMany({ + // where: { + // walletId, + // coinSymbol: coinSymbol.toLowerCase(), + // }, + // orderBy: [{ createdAt: "desc" }, { id: "desc" }], + // }); + + const formatted = transactions.map((tx: TransactionRow) => + formatTransaction(tx), + ); + + const validatedTransactions = TransactionsArraySchema.parse(formatted); + + return res.status(200).json(validatedTransactions); + } catch (error: any) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Error fetching transactions by coin:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// ====================================================================== + +export const getCoinStats = async (req: Request, res: Response) => { + try { + const { walletId, coinSymbol } = req.params; + + if (!walletId || !coinSymbol) { + return res.status(400).json({ error: "Coin symbol is required" }); + } + + const normalizedCoin = coinSymbol.toLowerCase(); + + const [statsRow] = await prisma.$queryRaw< + { + coinSymbol: string; + totalQuantity: number | string; + avgBuyingPrice: number | string; + transactionsCount: bigint | number; + minPrice: number | string | null; + maxPrice: number | string | null; + }[] + >` + SELECT + ${normalizedCoin}::text AS "coinSymbol", + ROUND( + COALESCE( + SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE -"quantity" END), + 0 + )::numeric, + 8 + ) AS "totalQuantity", + COALESCE( + ROUND( + ( + SUM(CASE WHEN "buyOrSell" = 'buy' THEN "price" * "quantity" ELSE 0 END) + / NULLIF(SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE 0 END), 0) + )::numeric, + 2 + ), + 0 + ) AS "avgBuyingPrice", + COUNT(*) AS "transactionsCount", + MIN("price") AS "minPrice", + MAX("price") AS "maxPrice" + FROM "Transaction" + WHERE "walletId" = ${walletId} AND "coinSymbol" = ${normalizedCoin} + GROUP BY "coinSymbol"; + `; + + // const transactions = await prisma.transaction.findMany({ + // where: { + // walletId, + // coinSymbol: coinSymbol.toLowerCase(), + // }, + // orderBy: [{ createdAt: "asc" }, { id: "asc" }], + // }); + + const stats = { + coinSymbol: normalizedCoin, + totalQuantity: Number(statsRow?.totalQuantity ?? 0), + avgBuyingPrice: Number(statsRow?.avgBuyingPrice ?? 0), + }; + + const validatedStats = CoinInfoSchema.parse(stats); + + return res.status(200).json(validatedStats); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Error getting coin stats:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// ====================================================================== + +export const getGroupedTransactionsForChart = async ( + req: Request, + res: Response, +) => { + try { + const { walletId } = req.params; + let { range } = req.query as { range?: string }; + const effectiveRange = range || "7d"; // Default + + if (!walletId) { + return res.status(400).json({ error: "Wallet ID is required." }); + } + const startDate = getStartDate(effectiveRange); + + // Get initial balance (parsed Decimal quantities safely) + const initialBalanceAggregations = await prisma.$queryRaw` + SELECT + "coinSymbol", + SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE -"quantity" END) AS "initialQuantity" + FROM "Transaction" + WHERE + "walletId" = ${walletId} AND "createdAt" < ${startDate} + GROUP BY "coinSymbol" + HAVING SUM(CASE WHEN "buyOrSell" = 'buy' THEN "quantity" ELSE -"quantity" END) != 0; + `; + const initialBalances: Record = {}; + for (const agg of initialBalanceAggregations as any[]) { + initialBalances[agg.coinSymbol] = Number(agg.initialQuantity) || 0; + } + + // TRANSACTION in range + const transactionsInPeriod = await prisma.$queryRaw< + { + coinSymbol: string; + createdAt: Date; + quantity: number | string; + buyOrSell: string; + }[] + >` + SELECT + t."coinSymbol", + t."createdAt", + t."quantity", + t."buyOrSell" + FROM "Transaction" t + WHERE + t."walletId" = ${walletId} + AND t."createdAt" BETWEEN ${startDate} AND NOW() + AND t."coinSymbol" IN ( + SELECT DISTINCT t2."coinSymbol" + FROM "Transaction" t2 + WHERE t2."walletId" = ${walletId} + ) + AND EXISTS ( + SELECT 1 + FROM "Wallet" w + WHERE w."id" = t."walletId" AND w."id" = ${walletId} + ) + AND NOW() > ( + SELECT MIN(t3."createdAt") + FROM "Transaction" t3 + WHERE t3."walletId" = t."walletId" + ) + AND t."createdAt" < ( + SELECT MAX(t4."createdAt") + INTERVAL '100 years' + FROM "Transaction" t4 + WHERE t4."walletId" = t."walletId" + ) + ORDER BY t."createdAt" ASC, t."id" ASC; + `; + + // const transactionsInPeriod = await prisma.transaction.findMany({ + // where: { + // walletId, + // createdAt: { gte: startDate }, + // }, + // select: { + // coinSymbol: true, + // createdAt: true, + // quantity: true, + // buyOrSell: true, + // }, + // orderBy: [{ createdAt: "asc" }, { id: "asc" }], + // }); + + const groupedData: Record< + string, + { + coinSymbol: string; + initialQuantity: number; + agregatedData: { + createdAt: Date; + quantity: number; + buyOrSell: string; + }[]; + } + > = {}; + + for (const tx of transactionsInPeriod) { + const symbol = tx.coinSymbol; + + if (!groupedData[symbol]) { + groupedData[symbol] = { + coinSymbol: symbol, + agregatedData: [], + initialQuantity: initialBalances[symbol] ?? 0, + }; + } + + groupedData[symbol].agregatedData.push({ + createdAt: tx.createdAt, + quantity: Number(tx.quantity), + buyOrSell: tx.buyOrSell, + }); + } + + // Add missing symbols - sleeping coins + Object.keys(initialBalances).forEach((symbol) => { + if (!groupedData[symbol]) { + groupedData[symbol] = { + coinSymbol: symbol, + agregatedData: [], + initialQuantity: initialBalances[symbol] ?? 0, + }; + } + }); + + const finalGroupedArray = Object.values(groupedData); + const validatedResponse = z + .array(CoinForChartSchema) + .parse(finalGroupedArray); + return res.status(200).json(validatedResponse); + } catch (error) { + if (error instanceof z.ZodError) return handleZodError(res, error); + console.error("Error getting coin stats:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; diff --git a/apps/backend/src/controllers/wallet/walletMutationHandlers.ts b/apps/backend/src/controllers/wallet/walletMutationHandlers.ts new file mode 100644 index 0000000..42861d7 --- /dev/null +++ b/apps/backend/src/controllers/wallet/walletMutationHandlers.ts @@ -0,0 +1,152 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import prisma from "../../prisma.js"; +import { + WalletSchema, + WalletCreateSchema, + WalletPatchSchema, +} from "../../models/WalletSchema.js"; +import { handleZodError } from "../../utils/helpers.js"; + +export const createWallet = async (req: Request, res: Response) => { + const userId = req.userId; + + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + + try { + const validatedData = WalletCreateSchema.parse(req.body); + const name = validatedData.name; + + const newWallet = await prisma.wallet.create({ + data: { + name: name, + userId: userId, + }, + }); + + const validatedWalletResponse = WalletSchema.parse(newWallet); + return res.status(201).json(validatedWalletResponse); + } catch (error: any) { + if (error.code === "P2002") { + const targetFields = error.meta?.target ?? []; + + const targetArray = Array.isArray(targetFields) ? targetFields : []; + + const field = targetArray.includes("name") + ? "name" + : targetArray.length > 0 + ? targetArray[0] + : "wallet"; + + return res.status(409).json({ + error: `A wallet with the same ${field} already exists for this user.`, + }); + } + + if (error instanceof z.ZodError) { + return handleZodError(res, error); + } + + console.error(error); + res.status(500).json({ message: "Internal Server Error" }); + } +}; + +//==================================================================== + +export const updateWallet = async (req: Request, res: Response) => { + const userId = req.userId; + const walletId = req.params.walletId; + + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + if (!walletId) { + return res.status(400).json({ error: "Wallet ID is required." }); + } + + try { + const validatedData = WalletPatchSchema.parse(req.body); //!patch + + if (Object.keys(validatedData).length === 0) { + return res.status(400).json({ error: "No fields provided for update." }); + } + + const updateData = { + ...(validatedData.name !== undefined ? { name: validatedData.name } : {}), + }; + + const updatedWallet = await prisma.wallet.update({ + where: { + id: walletId, + }, + data: updateData, + }); + + const validatedResponse = WalletSchema.parse(updatedWallet); + return res.status(200).json(validatedResponse); + } catch (error: any) { + if (error.code === "P2002") { + const targetFields = error.meta?.target ?? []; + const targetArray = Array.isArray(targetFields) ? targetFields : []; + const field = targetArray.includes("name") + ? "name" + : targetArray.length > 0 + ? targetArray[0] + : "wallet"; + + return res.status(409).json({ + error: `A wallet with the same ${field} already exists for this user.`, + }); + } + if (error.code === "P2025") { + return res + .status(404) + .json({ error: "Wallet not found or access denied." }); + } + + if (error instanceof z.ZodError) { + return handleZodError(res, error); + } + + console.error("Error updating wallet:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; + +//==================================================================== + +export const deleteWallet = async (req: Request, res: Response) => { + const userId = req.userId; + const walletId = req.params.walletId; + + if (!userId) { + return res.status(401).json({ error: "User not authenticated" }); + } + if (!walletId) { + return res.status(400).json({ error: "Wallet ID is required." }); + } + + try { + const deletedWallet = await prisma.wallet.delete({ + where: { + id: walletId, + }, + }); + + return res.status(200).json({ + message: `Wallet "${deletedWallet.name}" successfully deleted.`, + }); + } catch (error: any) { + if (error.code === "P2025") { + return res + .status(404) + .json({ error: "Wallet not found or access denied." }); + } + + console.error("Error deleting wallet:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}; diff --git a/apps/backend/src/controllers/walletController.ts b/apps/backend/src/controllers/wallet/walletQueryHandlers.ts similarity index 64% rename from apps/backend/src/controllers/walletController.ts rename to apps/backend/src/controllers/wallet/walletQueryHandlers.ts index 25e325f..19d9555 100644 --- a/apps/backend/src/controllers/walletController.ts +++ b/apps/backend/src/controllers/wallet/walletQueryHandlers.ts @@ -1,12 +1,8 @@ import type { Request, Response } from "express"; import { z } from "zod"; -import prisma from "../prisma.js"; -import { - WalletSchema, - WalletCreateSchema, - WalletPatchSchema, -} from "../models/WalletSchema.js"; -import { handleZodError } from "../utils/helpers.js"; +import prisma from "../../prisma.js"; +import { WalletSchema } from "../../models/WalletSchema.js"; +import { handleZodError } from "../../utils/helpers.js"; const WalletsArraySchema = z.array(WalletSchema); @@ -142,54 +138,6 @@ export const getWallets = async (req: Request, res: Response) => { //==================================================================== -export const createWallet = async (req: Request, res: Response) => { - const userId = req.userId; - - if (!userId) { - return res.status(401).json({ error: "User not authenticated" }); - } - - try { - const validatedData = WalletCreateSchema.parse(req.body); - const name = validatedData.name; - - const newWallet = await prisma.wallet.create({ - data: { - name: name, - userId: userId, - }, - }); - - const validatedWalletResponse = WalletSchema.parse(newWallet); - return res.status(201).json(validatedWalletResponse); - } catch (error: any) { - if (error.code === "P2002") { - const targetFields = error.meta?.target ?? []; - - const targetArray = Array.isArray(targetFields) ? targetFields : []; - - const field = targetArray.includes("name") - ? "name" - : targetArray.length > 0 - ? targetArray[0] - : "wallet"; - - return res.status(409).json({ - error: `A wallet with the same ${field} already exists for this user.`, - }); - } - - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - - console.error(error); - res.status(500).json({ message: "Internal Server Error" }); - } -}; - -//==================================================================== - export const getWallet = async (req: Request, res: Response) => { const userId = req.userId; const walletId = req.params.walletId; @@ -305,100 +253,3 @@ export const getWallet = async (req: Request, res: Response) => { return res.status(500).json({ error: "Internal Server Error" }); } }; - -//==================================================================== - -export const updateWallet = async (req: Request, res: Response) => { - const userId = req.userId; - const walletId = req.params.walletId; - - if (!userId) { - return res.status(401).json({ error: "User not authenticated" }); - } - if (!walletId) { - return res.status(400).json({ error: "Wallet ID is required." }); - } - - try { - const validatedData = WalletPatchSchema.parse(req.body); //!patch - - if (Object.keys(validatedData).length === 0) { - return res.status(400).json({ error: "No fields provided for update." }); - } - - const updateData = { - ...(validatedData.name !== undefined ? { name: validatedData.name } : {}), - }; - - const updatedWallet = await prisma.wallet.update({ - where: { - id: walletId, - }, - data: updateData, - }); - - const validatedResponse = WalletSchema.parse(updatedWallet); - return res.status(200).json(validatedResponse); - } catch (error: any) { - if (error.code === "P2002") { - const targetFields = error.meta?.target ?? []; - const targetArray = Array.isArray(targetFields) ? targetFields : []; - const field = targetArray.includes("name") - ? "name" - : targetArray.length > 0 - ? targetArray[0] - : "wallet"; - - return res.status(409).json({ - error: `A wallet with the same ${field} already exists for this user.`, - }); - } - if (error.code === "P2025") { - return res - .status(404) - .json({ error: "Wallet not found or access denied." }); - } - - if (error instanceof z.ZodError) { - return handleZodError(res, error); - } - - console.error("Error updating wallet:", error); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; - -//==================================================================== - -export const deleteWallet = async (req: Request, res: Response) => { - const userId = req.userId; - const walletId = req.params.walletId; - - if (!userId) { - return res.status(401).json({ error: "User not authenticated" }); - } - if (!walletId) { - return res.status(400).json({ error: "Wallet ID is required." }); - } - - try { - const deletedWallet = await prisma.wallet.delete({ - where: { - id: walletId, - }, - }); - - return res.status(200).json({ - message: `Wallet "${deletedWallet.name}" successfully deleted.`, - }); - } catch (error: any) { - if (error.code === "P2025") { - return res - .status(404) - .json({ error: "Wallet not found or access denied." }); - } - - console.error("Error deleting wallet:", error); - return res.status(500).json({ error: "Internal Server Error" }); - } -}; diff --git a/apps/backend/src/router/transactionRouter.ts b/apps/backend/src/router/transactionRouter.ts index 8f8c46f..761e042 100644 --- a/apps/backend/src/router/transactionRouter.ts +++ b/apps/backend/src/router/transactionRouter.ts @@ -8,11 +8,13 @@ import { updateTransaction, deleteTransaction, getPaginatedTransactions, +} from "../controllers/transaction/transactionCrudHandlers.js"; +import { getAllTransactionsGroupByCoinSymbol, getTransactionsByCoin, getCoinStats, getGroupedTransactionsForChart, -} from "../controllers/transactionController.js"; +} from "../controllers/transaction/transactionStatsHandlers.js"; import { protect } from "../middleware/authMiddleware.js"; transactionRouter.use(protect); diff --git a/apps/backend/src/router/walletRouter.ts b/apps/backend/src/router/walletRouter.ts index 03598b7..823deb7 100644 --- a/apps/backend/src/router/walletRouter.ts +++ b/apps/backend/src/router/walletRouter.ts @@ -9,11 +9,13 @@ import prisma from "../prisma.js"; import { getWallets, - createWallet, getWallet, +} from "../controllers/wallet/walletQueryHandlers.js"; +import { + createWallet, updateWallet, deleteWallet, -} from "../controllers/walletController.js"; +} from "../controllers/wallet/walletMutationHandlers.js"; import { protect } from "../middleware/authMiddleware.js"; const walletRouter = express.Router(); diff --git a/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx b/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx index 37bfc71..78a7b67 100644 --- a/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx +++ b/apps/frontend/src/modules/Transactions/AddTransactionPopup.tsx @@ -7,7 +7,11 @@ import { useGetCoinStatsQuery, } from "./transaction.api"; import { useCreateSwapMutation, useGetSwapSettingsQuery } from "./swap.api"; -import { getLocalDatetime } from "../../utils/functions"; +import { + extractApiErrorMessage, + getLocalDatetime, +} from "../../utils/functions"; +import { TransactionFormFields } from "./TransactionFormFields"; export function AddTransactionPopup({ coin }: { coin: Coin }) { const dispatch = useAppDispatch(); @@ -150,19 +154,7 @@ export function AddTransactionPopup({ coin }: { coin: Coin }) { ); }, 300); } catch (error: unknown) { - const message = - typeof error === "object" && - error !== null && - "data" in error && - typeof (error as { data?: unknown }).data === "object" && - (error as { data?: unknown }).data !== null && - "error" in - ((error as { data?: unknown }).data as Record) && - typeof ((error as { data?: unknown }).data as Record) - .error === "string" - ? ((error as { data?: unknown }).data as { error: string }).error - : "Failed to process transaction"; - setAlert(message); + setAlert(extractApiErrorMessage(error, "Failed to process transaction")); } }; @@ -207,98 +199,35 @@ export function AddTransactionPopup({ coin }: { coin: Coin }) { handleSubmit(); }} > -
- - -
- - {swapEnabled && - (form.buyOrSell === "buy" || form.buyOrSell === "sell") && ( -
- - { - setAlert(null); - setPayWithSwap(e.target.checked); - }} - className="w-4 h-4 cursor-pointer" - /> -
- )} - -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- - + + + { + setAlert(null); + setPayWithSwap(e.target.checked); + }} + className="w-4 h-4 cursor-pointer" + /> + + ) : null + } + /> ); diff --git a/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx b/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx index d98ca43..7d32739 100644 --- a/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx +++ b/apps/frontend/src/modules/Transactions/ChangeTransactionPopup.tsx @@ -7,7 +7,11 @@ import { } from "./transaction.api"; import { closePopup, openPopup } from "../../portals/popup.slice"; import { useGetAllCoinsQuery } from "../AllCrypto/all-crypto.api"; -import { getLocalDatetime } from "../../utils/functions"; +import { + extractApiErrorMessage, + getLocalDatetime, +} from "../../utils/functions"; +import { TransactionFormFields } from "./TransactionFormFields"; export function ChangeTransactionPopup({ transactionId, @@ -66,9 +70,6 @@ export function ChangeTransactionPopup({ return
Loading...
; const currentCoinInWallet = coinStats?.totalQuantity || 0; - const availableToSell = - currentCoinInWallet + - (transaction.buyOrSell === "sell" ? transaction.quantity : 0); const handleChange = ( e: React.ChangeEvent, @@ -99,6 +100,10 @@ export function ChangeTransactionPopup({ }; const handleSubmit = async () => { + const availableToSell = + currentCoinInWallet + + (transaction.buyOrSell === "sell" ? transaction.quantity : 0); + if (form.buyOrSell === "sell" && Number(form.quantity) > availableToSell) { setAlert("You don't have enough coins in your wallet."); return; @@ -123,19 +128,7 @@ export function ChangeTransactionPopup({ ); }, 300); } catch (error: unknown) { - const message = - typeof error === "object" && - error !== null && - "data" in error && - typeof (error as { data?: unknown }).data === "object" && - (error as { data?: unknown }).data !== null && - "error" in - ((error as { data?: unknown }).data as Record) && - typeof ((error as { data?: unknown }).data as Record) - .error === "string" - ? ((error as { data?: unknown }).data as { error: string }).error - : "Failed to update transaction"; - setAlert(message); + setAlert(extractApiErrorMessage(error, "Failed to update transaction")); } }; @@ -184,78 +177,14 @@ export function ChangeTransactionPopup({ handleSubmit(); }} > -
- - -
- -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- - + ); diff --git a/apps/frontend/src/modules/Transactions/TransactionFormFields.tsx b/apps/frontend/src/modules/Transactions/TransactionFormFields.tsx new file mode 100644 index 0000000..3ff2e57 --- /dev/null +++ b/apps/frontend/src/modules/Transactions/TransactionFormFields.tsx @@ -0,0 +1,109 @@ +import React from "react"; + +interface TransactionFormFieldsProps { + form: { + quantity: string; + price: string; + total_price: string; + createdAt: string; + buyOrSell: "buy" | "sell"; + }; + onChange: ( + e: React.ChangeEvent, + ) => void; + maxDateTime: string; + isLoading: boolean; + submitLabel: string; + loadingLabel: string; + /** Optional slot rendered between the Type select and the Price/Qty grid (e.g. swap toggle). */ + swapSlot?: React.ReactNode; +} + +export function TransactionFormFields({ + form, + onChange, + maxDateTime, + isLoading, + submitLabel, + loadingLabel, + swapSlot, +}: TransactionFormFieldsProps) { + return ( + <> +
+ + +
+ + {swapSlot} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + + ); +} diff --git a/apps/frontend/src/modules/Transactions/TransactionRow.tsx b/apps/frontend/src/modules/Transactions/TransactionRow.tsx new file mode 100644 index 0000000..57e9b11 --- /dev/null +++ b/apps/frontend/src/modules/Transactions/TransactionRow.tsx @@ -0,0 +1,92 @@ +import EditSVG from "../../assets/edit.svg"; +import DeleteSVG from "../../assets/cross.svg"; +import { formatPrice, formatQuantity } from "../../utils/functions"; + +export interface TransactionRowData { + id: string; + coinSymbol: string; + buyOrSell: "buy" | "sell"; + swapGroupId?: string | null; + quantity: number; + price: number; + createdAt: string | Date; + image: string; + name: string; +} + +interface TransactionRowProps { + transaction: TransactionRowData; + onEdit: () => void; + onDelete: () => void; + isDeleting: boolean; +} + +export function TransactionRow({ + transaction, + onEdit, + onDelete, + isDeleting, +}: TransactionRowProps) { + return ( +
+
+ {transaction.name} + {transaction.coinSymbol} +
+ +
+ {transaction.buyOrSell} + {transaction.swapGroupId && ( + + SWAP + + )} +
+ +
{formatQuantity(transaction.quantity)}
+
{formatPrice(transaction.price)}
+ +
+ {new Date(transaction.createdAt).toLocaleString()} +
+ + + + +
+ ); +} + +export function TransactionRowSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/frontend/src/modules/Transactions/WatchTransactionsPopup.tsx b/apps/frontend/src/modules/Transactions/WatchTransactionsPopup.tsx index 339cbe8..f7c899c 100644 --- a/apps/frontend/src/modules/Transactions/WatchTransactionsPopup.tsx +++ b/apps/frontend/src/modules/Transactions/WatchTransactionsPopup.tsx @@ -4,34 +4,12 @@ import { useGetTransactionsByCoinQuery, useDeleteTransactionMutation, } from "./transaction.api"; -import EditSVG from "../../assets/edit.svg"; -import DeleteSVG from "../../assets/cross.svg"; import { closePopup, openPopup } from "../../portals/popup.slice"; import { useAppDispatch, useAppSelector } from "../../store"; import { ChangeTransactionPopup } from "./ChangeTransactionPopup"; import { useGetAllCoinsQuery } from "../AllCrypto/all-crypto.api"; -import { formatPrice, formatQuantity } from "../../utils/functions"; - -const extractApiErrorMessage = ( - error: unknown, - fallback = "Failed", -): string => { - if ( - typeof error === "object" && - error !== null && - "data" in error && - typeof (error as { data?: unknown }).data === "object" && - (error as { data?: unknown }).data !== null && - "error" in - ((error as { data?: unknown }).data as Record) && - typeof ((error as { data?: unknown }).data as Record) - .error === "string" - ) { - return ((error as { data?: unknown }).data as { error: string }).error; - } - - return fallback; -}; +import { extractApiErrorMessage } from "../../utils/functions"; +import { TransactionRow, TransactionRowSkeleton } from "./TransactionRow"; export function WatchTransactionsPopup({ coinSymbol, @@ -143,68 +121,15 @@ export function WatchTransactionsPopup({
{isPageLoading - ? skeletons.map((_, index) => ( -
-
-
-
-
-
-
-
-
-
-
-
- )) + ? skeletons.map((_, index) => ) : transactionsToShow.map((transaction) => ( -
-
- {transaction.name} - - {transaction.coinSymbol} - -
-
- {transaction.buyOrSell} - {transaction.swapGroupId && ( - - SWAP - - )} -
-
{formatQuantity(transaction.quantity)}
-
{formatPrice(transaction.price)}
- -
- {new Date(transaction.createdAt).toLocaleString()} -
- - - - -
+ transaction={transaction} + onEdit={() => handleChangeTransaction(transaction.id)} + onDelete={() => handleDeleteTransaction(transaction.id)} + isDeleting={isDeleting} + /> ))} {!isPageLoading && transactionsToShow.length === 0 && ( diff --git a/apps/frontend/src/utils/functions.ts b/apps/frontend/src/utils/functions.ts index 4345fab..936dc6a 100644 --- a/apps/frontend/src/utils/functions.ts +++ b/apps/frontend/src/utils/functions.ts @@ -91,6 +91,26 @@ export const formatPrice = (num: number | string) => { } }; +export const extractApiErrorMessage = ( + error: unknown, + fallback = "Failed", +): string => { + if ( + typeof error === "object" && + error !== null && + "data" in error && + typeof (error as { data?: unknown }).data === "object" && + (error as { data?: unknown }).data !== null && + "error" in + ((error as { data?: unknown }).data as Record) && + typeof ((error as { data?: unknown }).data as Record) + .error === "string" + ) { + return ((error as { data?: unknown }).data as { error: string }).error; + } + return fallback; +}; + export const getLocalDatetime = (dateInput?: string | Date): string => { let date: Date; if (dateInput === undefined) {