summaryrefslogtreecommitdiff
path: root/jb-ui/src/components/Wallet.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'jb-ui/src/components/Wallet.tsx')
-rw-r--r--jb-ui/src/components/Wallet.tsx464
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