Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions ui/src/pages/invoices/list/columns.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import { Amount, type DataTableColumnDef, Link, Text } from "@raystack/apsara";
import dayjs from "dayjs";
import type { V1Beta1SearchInvoicesResponseInvoice } from "~/api/frontier";
import { NULL_DATE } from "~/utils/constants";
import type { SearchInvoicesResponse_Invoice } from "@raystack/proton/frontier";
import {
isNullTimestamp,
TimeStamp,
timestampToDate,
} from "~/utils/connect-timestamp";

export const getColumns = (): DataTableColumnDef<
V1Beta1SearchInvoicesResponseInvoice,
SearchInvoicesResponse_Invoice,
unknown
>[] => {
return [
{
accessorKey: "created_at",
accessorKey: "createdAt",
header: "Billed on",
filterType: "date",
enableColumnFilter: true,
cell: ({ getValue }) => {
const value = getValue() as string;
return value !== NULL_DATE ? dayjs(value).format("YYYY-MM-DD") : "-";
const value = getValue() as TimeStamp;
const date = isNullTimestamp(value)
? "-"
: dayjs(timestampToDate(value)).format("YYYY-MM-DD");
return <Text>{date}</Text>;
},
enableHiding: true,
enableSorting: true,
},
{
enableColumnFilter: true,
accessorKey: "state",
filterOptions: ["paid", "open", "draft"].map((value) => ({
filterOptions: ["paid", "open", "draft"].map(value => ({
value: value,
label: value,
})),
Expand All @@ -34,7 +41,7 @@ export const getColumns = (): DataTableColumnDef<
},
},
{
accessorKey: "org_title",
accessorKey: "orgTitle",
header: "Organization",
cell: ({ row, getValue }) => {
return getValue() as string;
Expand All @@ -44,13 +51,12 @@ export const getColumns = (): DataTableColumnDef<
header: "Amount",
accessorKey: "amount",
cell: ({ row, getValue }) => {
const currency = row?.original?.currency;
const value = getValue() as number;
return <Amount value={value} currency={currency} />;
const value = Number(getValue());
return <Amount value={value} currency={row.original.currency} />;
},
},
{
accessorKey: "invoice_link",
accessorKey: "invoiceLink",
header: "",
cell: ({ row, getValue }) => {
const link = getValue() as string;
Expand Down
116 changes: 88 additions & 28 deletions ui/src/pages/invoices/list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import {
EmptyState,
Flex,
} from "@raystack/apsara";
import { useMemo, useState } from "react";
import PageTitle from "~/components/page-title";
import { InvoicesNavabar } from "./navbar";
import styles from "./list.module.css";
import InvoicesIcon from "~/assets/icons/invoices.svg?react";
import { getColumns } from "./columns";
import { api } from "~/api";
import { useCallback } from "react";
import { useRQL } from "~/hooks/useRQL";
import type { V1Beta1SearchInvoicesResponseInvoice } from "~/api/frontier";
import { useInfiniteQuery } from "@connectrpc/connect-query";
import { AdminServiceQueries } from "@raystack/proton/frontier";
import {
getConnectNextPageParam,
DEFAULT_PAGE_SIZE,
} from "~/utils/connect-pagination";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query";

const NoInvoices = () => {
return (
Expand All @@ -29,50 +34,105 @@ const NoInvoices = () => {
);
};

const DEFAULT_SORT: DataTableSort = { name: "created_at", order: "desc" };
const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" };
const INITIAL_QUERY: DataTableQuery = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
};

export const InvoicesList = () => {
const columns = getColumns();
const [tableQuery, setTableQuery] = useState<DataTableQuery>(INITIAL_QUERY);

const apiCallback = useCallback(
async (apiQuery: DataTableQuery = {}) =>
await api.adminServiceSearchInvoices(apiQuery).then((res) => res.data),
[],
);
const query = transformDataTableQueryToRQLRequest(tableQuery, {
fieldNameMapping: {
createdAt: "created_at",
},
});

const {
data,
loading: isLoading,
query,
onTableQueryChange,
fetchMore,
} = useRQL<V1Beta1SearchInvoicesResponseInvoice>({
initialQuery: { offset: 0, sort: [DEFAULT_SORT] },
dataKey: "invoices",
key: "invoices",
fn: apiCallback,
onError: (error: Error | unknown) =>
console.error("Failed to fetch invoices:", error),
});
data: infiniteData,
isLoading,
isFetchingNextPage,
fetchNextPage,
error,
isError,
hasNextPage,
} = useInfiniteQuery(
AdminServiceQueries.searchInvoices,
{ query },
{
pageParamKey: "query",
getNextPageParam: lastPage =>
getConnectNextPageParam(lastPage, { query: query }, "invoices"),
staleTime: 0,
refetchOnWindowFocus: false,
retry: 1,
retryDelay: 1000,
},
);

const data = infiniteData?.pages?.flatMap(page => page?.invoices || []) || [];

const onTableQueryChange = (newQuery: DataTableQuery) => {
setTableQuery({
...newQuery,
offset: 0,
limit: newQuery.limit || DEFAULT_PAGE_SIZE,
});
};

const handleLoadMore = async () => {
try {
if (!hasNextPage) return;
await fetchNextPage();
} catch (error) {
console.error("Error loading more invoices:", error);
}
};

const columns = getColumns();

const loading = isLoading || isFetchingNextPage;

if (isError) {
console.error("ConnectRPC Error:", error);
return (
<>
<PageTitle title="Invoices" />
<EmptyState
icon={<ExclamationTriangleIcon />}
heading="Error Loading Invoices"
subHeading={
error?.message ||
"Something went wrong while loading invoices. Please try again."
}
/>
</>
);
}

const tableClassName =
data.length || loading ? styles["table"] : styles["table-empty"];

return (
<>
<PageTitle title="Invoices" />
<DataTable
query={tableQuery}
columns={columns}
data={data}
isLoading={isLoading}
isLoading={loading}
defaultSort={DEFAULT_SORT}
onTableQueryChange={onTableQueryChange}
mode="server"
onLoadMore={fetchMore}
>
onLoadMore={handleLoadMore}>
<Flex direction="column" style={{ width: "100%" }}>
<InvoicesNavabar searchQuery={query.search || ""} />
<InvoicesNavabar searchQuery={tableQuery.search || ""} />
<DataTable.Toolbar />
<DataTable.Content
classNames={{
root: styles["table-wrapper"],
table: tableClassName,
header: styles["table-header"],
}}
emptyState={<NoInvoices />}
Expand Down
34 changes: 20 additions & 14 deletions ui/src/pages/invoices/list/list.module.css
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
.navbar {
padding: var(--rs-space-4) var(--rs-space-7);
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
background: var(--rs-color-background-base-primary);
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--rs-space-4) var(--rs-space-7);
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
background: var(--rs-color-background-base-primary);
display: flex;
align-items: center;
justify-content: space-between;
}

.table {
height: auto;
height: auto;
}

.table-empty {
height: 100%;
height: 100%;
}

.empty-state {
height: 100%;
height: 100%;
}

.table-wrapper {
/* Navbar Height + Toolbar height */
max-height: calc(100vh - 90px);
overflow: scroll;
/* Navbar Height + Toolbar height */
max-height: calc(100vh - 90px);
overflow: scroll;
}

.table-header {
/* position: relative; */
z-index: 2;
/* position: relative; */
z-index: 2;
}

.table th:first-child,
.table td:first-child {
padding-left: var(--rs-space-7);
max-width: 200px;
}
10 changes: 5 additions & 5 deletions ui/src/pages/invoices/list/navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { DataTable, Flex, IconButton, Text } from "@raystack/apsara";
import styles from "./list.module.css";
import InvoicesIcon from "~/assets/icons/invoices.svg?react";
import { useState } from "react";
import { FocusEvent, useState } from "react";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";

export const InvoicesNavabar = ({ searchQuery }: { searchQuery: string }) => {
const [showSearch, setShowSearch] = useState(searchQuery ? true : false);
function toggleSearch() {
setShowSearch((prev) => !prev);
setShowSearch(prev => !prev);
}

function onSearchBlur(e: React.FocusEvent<HTMLInputElement>) {
function onSearchBlur(e: FocusEvent<HTMLInputElement>) {
const value = e.target.value;
if (!value) {
setShowSearch(false);
Expand All @@ -31,14 +31,14 @@ export const InvoicesNavabar = ({ searchQuery }: { searchQuery: string }) => {
showClearButton={true}
size="small"
onBlur={onSearchBlur}
autoFocus
/>
) : (
<IconButton
size={3}
aria-label="Search"
data-test-id="admin-ui-search-invoices-btn"
onClick={toggleSearch}
>
onClick={toggleSearch}>
<MagnifyingGlassIcon />
</IconButton>
)}
Expand Down
Loading