diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..4515726 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( @@ -172,10 +173,13 @@ export default function GovernancePage() { }, []); useEffect(() => { - load(); + const timeout = window.setTimeout(load, 0); // Refresh every 30 s for real-time vote counts - const interval = setInterval(load, 30_000); - return () => clearInterval(interval); + const interval = window.setInterval(load, 30_000); + return () => { + window.clearTimeout(timeout); + window.clearInterval(interval); + }; }, [load]); const filtered = diff --git a/app/invoices/batch/page.tsx b/app/invoices/batch/page.tsx new file mode 100644 index 0000000..bbf4bbd --- /dev/null +++ b/app/invoices/batch/page.tsx @@ -0,0 +1,5 @@ +import BatchInvoiceSubmissionPage from "@/components/BatchInvoiceSubmissionPage"; + +export default function InvoiceBatchPage() { + return ; +} diff --git a/src/components/BatchInvoiceSubmissionPage.tsx b/src/components/BatchInvoiceSubmissionPage.tsx new file mode 100644 index 0000000..7d78735 --- /dev/null +++ b/src/components/BatchInvoiceSubmissionPage.tsx @@ -0,0 +1,541 @@ +"use client"; + +import { ChangeEvent, useMemo, useState } from "react"; +import Navbar from "@/components/Navbar"; +import Footer from "@/components/Footer"; +import TokenSelector, { TokenAmount } from "@/components/TokenSelector"; +import { useApprovedTokens } from "@/hooks/useApprovedTokens"; +import { useToast } from "@/context/ToastContext"; +import { useWallet } from "@/context/WalletContext"; +import { + createBatchInvoiceDraft, + MAX_BATCH_INVOICE_ROWS, + parseBatchInvoiceCsv, + summarizeBatchInvoices, + validateBatchInvoiceRows, + type BatchInvoiceDraft, + type BatchInvoiceErrors, + type PreparedBatchInvoice, +} from "@/utils/batchInvoiceSubmission"; +import { formatAmountFromUnits, getMinimumDueDate } from "@/utils/invoiceSubmission"; +import { NETWORK_NAME } from "@/constants"; +import { submitBatchInvoicesTransaction } from "@/utils/soroban"; + +type BatchInputMode = "csv" | "form"; + +interface BatchResultRow { + rowNumber: number; + payer: string; + status: "success" | "failed"; + message: string; +} + +const INITIAL_ROWS = [createBatchInvoiceDraft(0)]; + +export default function BatchInvoiceSubmissionPage() { + const { address, isConnected, connect, signTx, networkMismatch } = useWallet(); + const { addToast, updateToast } = useToast(); + const { tokens, defaultToken, isLoading: tokensLoading } = useApprovedTokens(); + const [mode, setMode] = useState("csv"); + const [csvText, setCsvText] = useState(""); + const [rows, setRows] = useState(INITIAL_ROWS); + const [isSubmitting, setIsSubmitting] = useState(false); + const [resultRows, setResultRows] = useState([]); + const minimumDueDate = getMinimumDueDate(); + + const effectiveRows = mode === "csv" ? parseBatchInvoiceCsv(csvText) : rows; + const validatedRows = useMemo( + () => validateBatchInvoiceRows(effectiveRows, tokens), + [effectiveRows, tokens], + ); + const summary = useMemo( + () => summarizeBatchInvoices(validatedRows, tokens), + [tokens, validatedRows], + ); + const validPreparedRows = validatedRows + .map((row) => row.prepared) + .filter((row): row is PreparedBatchInvoice => Boolean(row)); + const hasValidationErrors = validatedRows.some((row) => Object.keys(row.errors).length > 0); + const canSubmit = + isConnected && + !networkMismatch && + validPreparedRows.length > 0 && + !hasValidationErrors && + validPreparedRows.length <= MAX_BATCH_INVOICE_ROWS && + !isSubmitting; + + const updateRow = (rowId: string, field: keyof Omit, value: string) => { + setRows((current) => + current.map((row) => (row.id === rowId ? { ...row, [field]: value } : row)) + ); + setResultRows([]); + }; + + const addRow = () => { + setRows((current) => + current.length >= MAX_BATCH_INVOICE_ROWS + ? current + : [...current, createBatchInvoiceDraft(current.length)] + ); + }; + + const removeRow = (rowId: string) => { + setRows((current) => (current.length === 1 ? current : current.filter((row) => row.id !== rowId))); + }; + + const handleCsvUpload = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + setCsvText(await file.text()); + setResultRows([]); + }; + + const handleSubmit = async () => { + if (!address) { + await connect(); + return; + } + + if (!canSubmit) return; + + setIsSubmitting(true); + setResultRows([]); + const toastId = addToast({ + type: "pending", + title: "Submitting invoice batch...", + message: "Please sign one atomic batch transaction in Freighter.", + }); + + try { + const result = await submitBatchInvoicesTransaction({ + freelancer: address, + invoices: validPreparedRows, + signTx, + }); + + setResultRows( + validPreparedRows.map((row, index) => ({ + rowNumber: index + 1, + payer: row.payer, + status: "success", + message: `Included in transaction ${result.txHash.slice(0, 10)}...`, + })) + ); + updateToast(toastId, { + type: "success", + title: "Batch submitted", + message: `${validPreparedRows.length} invoices were submitted atomically.`, + txHash: result.txHash, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Batch submission failed."; + setResultRows( + validPreparedRows.map((row, index) => ({ + rowNumber: index + 1, + payer: row.payer, + status: "failed", + message, + })) + ); + updateToast(toastId, { + type: "error", + title: "Batch failed", + message, + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ +
+
+
+
+

+ Freelancer Portal +

+

Batch invoice submission

+
+ {!isConnected ? ( + + ) : ( +
+ Wallet + {address?.slice(0, 8)}...{address?.slice(-6)} +
+ )} +
+ + {networkMismatch && ( +
+ Switch Freighter to {NETWORK_NAME} before submitting this batch. +
+ )} + +
+
+
+ + +
+ + {mode === "csv" ? ( +
+
+ +