diff options
Diffstat (limited to 'jb-ui/src/components/Wallet.tsx')
| -rw-r--r-- | jb-ui/src/components/Wallet.tsx | 464 |
1 files changed, 464 insertions, 0 deletions
diff --git a/jb-ui/src/components/Wallet.tsx b/jb-ui/src/components/Wallet.tsx new file mode 100644 index 0000000..e4fc694 --- /dev/null +++ b/jb-ui/src/components/Wallet.tsx @@ -0,0 +1,464 @@ +import { + UserLedgerTransactionsResponse, + UserLedgerTransactionsResponseTransactionsInner, + WalletApi +} from "@/api_fsb"; +import { useAppDispatch, useAppSelector } from "@/hooks"; +import { bpid, formatCentsToUSD, } from "@/lib/utils"; +import { + setTxPagination, setTxTotalItems, setTxTotalPages, + setUserLedgerSummary, + setUserLedgerTxs +} from "@/models/appSlice"; +import { + RowData, createColumnHelper, flexRender, getCoreRowModel, + useReactTable +} from "@tanstack/react-table"; +import moment from "moment"; +import { useEffect, useState } from "react"; + +declare module '@tanstack/react-table' { + interface ColumnMeta<TData extends RowData, TValue> { + align?: 'left' | 'center' | 'right'; + } +} + +// Function to calculate background color based on balance +const getBackgroundColor = (balance: number | undefined): string => { + if (balance === undefined || balance === null) return 'bg-blue-600'; + + if (balance > 0) { + return 'bg-blue-600'; + } else if (balance <= -100) { + return 'bg-red-600'; + } else { + // Interpolate between blue and red for values between 0 and -100 + // progress goes from 0 (at balance = 0) to 1 (at balance = -100) + const progress = Math.abs(balance) / 100; + + // Blue RGB: (37, 99, 235) - Tailwind blue-600 + // Red RGB: (220, 38, 38) - Tailwind red-600 + const r = Math.round(37 + (220 - 37) * progress); + const g = Math.round(99 + (38 - 99) * progress); + const b = Math.round(235 + (38 - 235) * progress); + + return `rgb(${r}, ${g}, ${b})`; + } +}; + + +const WalletHeader = () => { + const amount = useAppSelector(state => state.app.userWalletBalance)?.amount ?? 0 + + const bgStyle = typeof getBackgroundColor(amount) === 'string' && + getBackgroundColor(amount).startsWith('bg-') + ? {} + : { backgroundColor: getBackgroundColor(amount) }; + + const bgClass = typeof getBackgroundColor(amount) === 'string' && + getBackgroundColor(amount).startsWith('bg-') + ? getBackgroundColor(amount) + : ''; + + return ( + <div className={`w-full py-1 px-1 text-white rounded ${bgClass}`} + style={bgStyle} + > + <h2 className="text-xs font-semibold flex justify-between items-center"> + <span className="text-left">User Wallet:</span> + + {amount >= 0 ? ( + <span className='text-emerald-50'> + {`${formatCentsToUSD(Math.abs(amount))}`} + </span> + ) : ( + <span className='text-rose-50'> + {`(${formatCentsToUSD(Math.abs(amount))})`} + </span> + )} + + </h2> + </div> + ) +}; + +const WalletSummary = () => { + const summary = useAppSelector(state => state.app.userLedgerSummary) + + const survey_completes = summary?.bp_payment?.entry_count ?? 0 + const survey_completes_f = (survey_completes ?? 0).toLocaleString('en-US') + + const survey_adjustments = summary?.bp_adjustment?.entry_count ?? 0 + const survey_adjustments_f = (survey_adjustments ?? 0).toLocaleString('en-US') + + const min_payout: number = summary?.bp_payment?.min_amount ?? 0 + const max_payout: number = summary?.bp_payment?.max_amount ?? 0 + const adj_total: number = summary?.bp_adjustment?.total_amount ?? 0 + const user_payments_total: number = (summary?.user_payout_request?.total_amount ?? 0) * -1 + + return ( + <div className="grid grid-cols-[auto_1fr] text-xs"> + <div className="label text-left">Completes:</div> + <div className="value text-right">{survey_completes_f} surveys</div> + + <div className="label text-left">Avg Payouts:</div> + <div className="value text-right">{formatCentsToUSD(min_payout)} – {formatCentsToUSD(max_payout)}</div> + + <div className="label text-left">Survey Adjustments:</div> + <div className="value text-right">{survey_adjustments_f}</div> + + <div className="label text-left">Adjustment Amount:</div> + <div className="value text-right"> + {adj_total >= 0 ? ( + <span className='text-emerald-300'> + {`${formatCentsToUSD(adj_total)}`} + </span> + ) : ( + <span className='text-rose-300'> + {`(${formatCentsToUSD(Math.abs(adj_total))})`} + </span> + )} + </div> + + <div className="label text-left">User Payments:</div> + <div className="value text-right"> + {user_payments_total >= 0 ? ( + <span className='text-emerald-300'> + {`${formatCentsToUSD(user_payments_total)}`} + </span> + ) : ( + <span className='text-rose-300'> + {`(${formatCentsToUSD(Math.abs(user_payments_total))})`} + </span> + )} + + </div> + </div > + ) +}; + +const WalletTransactionsHeader = () => { + const tx_cnt = useAppSelector(state => state.app.txTotalItems) + const txt_cnt_f = (tx_cnt ?? 0).toLocaleString('en-US') + + const [showTx, setShowTx] = useState(false); + + return ( + <div className="w-full bg-blue-600 text-white rounded text-left"> + <div className="hover:cursor-pointer hover:bg-blue-700 transition-colors duration-200" + onClick={() => setShowTx(!showTx)}> + + <h2 className="text-xs font-semibold rounded flex justify-between + py-1 px-1" + onClick={() => setShowTx(!showTx)} + > + <span>Total Transactions:</span> <span>{txt_cnt_f}</span> + </h2> + + <h4 className="text-[10px] text-center italic text-blue-300 py-1" + onClick={() => setShowTx(!showTx)} + > + (Click to {showTx ? 'hide' : 'show'}) + </h4> + + </div> + + {showTx && ( + <> + <WalletTransactions /> + </> + )} + + </div> + ) +}; + + +const WalletTransactions = () => { + const dispatch = useAppDispatch() + const bpuid = useAppSelector(state => state.app.bpuid) + const userLedgerTxs = useAppSelector(state => state.app.userLedgerTxs); + + const txPagination = useAppSelector(state => state.app.txPagination); + const txTotalPages = useAppSelector(state => state.app.txTotalPages); + + useEffect(() => { + if (!bpuid) return; + + new WalletApi().getUserTransactionHistoryProductIdTransactionHistoryGet( + bpid, // productId + bpuid, // bpuid + undefined, // createdAfter + undefined, // createdBefore + "-created", // orderBy + txPagination.pageIndex + 1, // page + txPagination.pageSize // pageSize + ).then(res => { + const response = res.data as UserLedgerTransactionsResponse; + console.log("Wallet: fetched user ledger", response); + dispatch(setUserLedgerSummary(response.summary)); + + dispatch(setUserLedgerTxs(response.transactions ?? [])) + dispatch(setTxTotalItems(response.total!)) + dispatch(setTxTotalPages(response.pages!)) + }); + + }, [bpuid, txPagination.pageIndex, txPagination.pageSize]); + + const columnHelper = createColumnHelper<UserLedgerTransactionsResponseTransactionsInner>() + const columns = [ + columnHelper.accessor('created', { + header: () => 'Date', + cell: (info) => moment(info.getValue()).format('M/D/YY H:mm'), + size: 110, + meta: { + align: 'left' + } + }), + columnHelper.accessor('description', { + header: () => 'Type', + cell: props => { + const desc = props.getValue(); + switch (desc) { + case 'Task Complete': + return 'Survey 🎉'; + case 'Task Adjustment': + return 'Reject ⚠️'; + case 'Compensation Bonus': + return 'Bonus 🎁'; + case 'HIT Bonus': + return 'Bonus'; + case 'HIT Reward': + return 'Assignment'; + default: + return desc; + } + }, + size: 80, + meta: { + align: 'left' + } + }), + columnHelper.accessor('amount', { + header: () => 'Amount', + cell: (props) => { + const val = props.renderValue() as number; + const isPositive = val >= 0; + const emoji = isPositive ? "⬆\uFE0E" : "⬇\uFE0E"; + const colorClass = isPositive ? 'font-variant-emoji-text text-emerald-300' : 'font-variant-emoji-text text-rose-300'; + + return ( + <span className={colorClass}> + {`${emoji} ${formatCentsToUSD(Math.abs(val))}`} + </span> + ) + }, + size: 70, + meta: { + align: 'center' + } + }), + columnHelper.accessor('balance_after', { + header: () => 'Balance', + cell: (props) => { + const val = props.renderValue() as number; + const isPositive = val >= 0; + const colorClass = isPositive ? 'text-emerald-300' : 'text-rose-300'; + + return ( + <> + {isPositive ? ( + <span className={colorClass}> + {`${formatCentsToUSD(Math.abs(val))}`} + </span> + ) : ( + <span className={colorClass}> + {`(${formatCentsToUSD(Math.abs(val))})`} + </span> + )} + </> + ) + }, + size: 70, + meta: { + align: 'center' + } + }), + ] + + const table = useReactTable({ + 'data': userLedgerTxs, + 'columns': columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: txTotalPages, + onPaginationChange: (updater) => { + dispatch(setTxPagination( + typeof updater === 'function' ? updater(txPagination) : updater + )); + }, + state: { + pagination: txPagination, + }, + }); + + return ( + <div className="w-full border-t-1 border-blue-800 p-2"> + <table className="text-xs table-fixed border-collapse"> + <thead className="font-bold"> + {table.getHeaderGroups().map((headerGroup) => ( + + <tr key={headerGroup.id}> + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align || 'left'; + const alignClass = align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left'; + + return ( + <th + key={header.id} + className={`p-0 m-0 ${alignClass}`} + style={{ + width: `${header.column.getSize()}px`, + }} + > + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + </th> + ) + })} + </tr> + ))} + </thead> + <tbody> + {table.getRowModel().rows.map((row) => ( + <tr key={row.id}> + {row.getVisibleCells().map((cell) => { + const align = cell.column.columnDef.meta?.align || 'left'; + const alignClass = align === 'center' ? 'text-center' : + align === 'right' ? 'text-right' : 'text-left'; + + return ( + <td key={cell.id} + className={`p-0 ${alignClass}`} + style={{ + width: `${cell.column.getSize()}px`, + }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </td> + ) + })} + </tr> + ))} + </tbody> + + <tfoot> + <tr> + <td colSpan={columns.length} className="p-2"> + <div className="flex items-center justify-between text-xs"> + + {/* Page info */} + <div className="text-blue-50"> + Page {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount()} + </div> + + {/* Navigation buttons */} + <div className="flex gap-8 text-xs"> + <button + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + className="px-3 py-1 border border-blue-200 rounded + font-variant-emoji-text + hover:cursor-pointer hover:bg-blue-700 + disabled:opacity-50 disabled:cursor-not-allowed" + > + {'⬅\uFE0E'} + </button> + <button + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + className="px-3 py-1 border border-blue-200 rounded + font-variant-emoji-text + hover:cursor-pointer hover:bg-blue-700 + disabled:opacity-50 disabled:cursor-not-allowed" + > + {'⮕\uFE0E'} + </button> + </div> + + {/* Page size selector */} + <select + value={table.getState().pagination.pageSize} + onChange={e => table.setPageSize(Number(e.target.value))} + className="px-2 py-1 border border-blue-200 rounded + hover:cursor-pointer hover:bg-blue-700" + > + {[10, 20, 30, 40].map(pageSize => ( + <option key={pageSize} value={pageSize}> + Show {pageSize} + </option> + ))} + </select> + </div> + </td> + </tr> + </tfoot> + + </table> + </div> + ) +}; + + +const Wallet = () => { + const dispatch = useAppDispatch() + const bpuid = useAppSelector(state => state.app.bpuid) + const pagination = useAppSelector(state => state.app.txPagination); + + useEffect(() => { + if (!bpuid) return; + + new WalletApi().getUserTransactionHistoryProductIdTransactionHistoryGet( + bpid, // productId + bpuid, // bpuid + undefined, // createdAfter + undefined, // createdBefore + "-created", // orderBy + pagination.pageIndex + 1, // page + pagination.pageSize // pageSize + ).then(res => { + const response = res.data as UserLedgerTransactionsResponse; + dispatch(setUserLedgerSummary(response.summary)); + dispatch(setUserLedgerTxs(response.transactions ?? [])) + + dispatch(setTxPagination({ + pageIndex: (response.page ?? 1) - 1, + pageSize: response.size ?? 10, + })) + dispatch(setTxTotalItems(response.total ?? 0)) + dispatch(setTxTotalPages(response.pages ?? 9)) + }); + + }, [bpuid]) + + if (!bpuid) return null; + + return ( + <div className="fixed top-2 left-2 p-2 rounded + bg-blue-500 text-white shadow-lg z-50 + font-mono + max-h-[calc(100vh-2.5rem)] overflow-y-auto + max-w-[96vw] + "> + <WalletHeader /> + <WalletSummary /> + <WalletTransactionsHeader /> + </div> + ) +}; + + +export default Wallet;
\ No newline at end of file |
