InvoicesSettings.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import dayjs from 'dayjs'
  2. import { ChevronLeft, ChevronRight, FileText, Receipt, ScrollText } from 'lucide-react'
  3. import { useEffect, useState } from 'react'
  4. import { toast } from 'sonner'
  5. import {
  6. Button,
  7. Card,
  8. CardFooter,
  9. cn,
  10. Table,
  11. TableBody,
  12. TableCell,
  13. TableHead,
  14. TableHeader,
  15. TableRow,
  16. } from 'ui'
  17. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  18. import InvoicePayButton from './InvoicePayButton'
  19. import { InvoiceStatus } from '@/components/interfaces/Billing/Invoices.types'
  20. import InvoiceStatusBadge from '@/components/interfaces/Billing/InvoiceStatusBadge'
  21. import AlertError from '@/components/ui/AlertError'
  22. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  23. import PartnerManagedResource from '@/components/ui/PartnerManagedResource'
  24. import { getInvoice } from '@/data/invoices/invoice-query'
  25. import { getInvoiceReceipt } from '@/data/invoices/invoice-receipt-query'
  26. import { useInvoicesCountQuery } from '@/data/invoices/invoices-count-query'
  27. import { useInvoicesQuery } from '@/data/invoices/invoices-query'
  28. import { isPartnerBillingOrganization } from '@/data/organizations/managed-by-utils'
  29. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  30. import { MANAGED_BY } from '@/lib/constants/infrastructure'
  31. import { formatCurrency } from '@/lib/helpers'
  32. import { Organization } from '@/types/base'
  33. const PAGE_LIMIT = 5
  34. const getPartnerManagedResourceCta = (selectedOrganization: Organization) => {
  35. if (selectedOrganization.managed_by === MANAGED_BY.VERCEL_MARKETPLACE) {
  36. return {
  37. installationId: selectedOrganization?.partner_id,
  38. path: '/invoices',
  39. }
  40. }
  41. if (selectedOrganization.managed_by === MANAGED_BY.AWS_MARKETPLACE) {
  42. return {
  43. organizationSlug: selectedOrganization?.slug,
  44. overrideUrl: 'https://console.aws.amazon.com/billing/home#/bills',
  45. }
  46. }
  47. }
  48. export const InvoicesSettings = () => {
  49. const [page, setPage] = useState(1)
  50. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  51. const slug = selectedOrganization?.slug
  52. const isPartnerBilledOrganization = isPartnerBillingOrganization(
  53. selectedOrganization?.billing_partner
  54. )
  55. const offset = (page - 1) * PAGE_LIMIT
  56. const { data: count, isError: isErrorCount } = useInvoicesCountQuery(
  57. {
  58. slug,
  59. },
  60. { enabled: !isPartnerBilledOrganization }
  61. )
  62. const {
  63. data,
  64. error,
  65. isPending: isLoading,
  66. isError,
  67. } = useInvoicesQuery(
  68. {
  69. slug,
  70. offset,
  71. limit: PAGE_LIMIT,
  72. },
  73. { enabled: !isPartnerBilledOrganization }
  74. )
  75. const invoices = data || []
  76. useEffect(() => {
  77. setPage(1)
  78. }, [slug])
  79. const fetchInvoice = async (id: string) => {
  80. try {
  81. const invoice = await getInvoice({ invoiceId: id, slug })
  82. if (invoice?.invoice_pdf) window.open(invoice.invoice_pdf, '_blank')
  83. } catch (error: any) {
  84. toast.error(`Failed to fetch the selected invoice: ${error.message}`)
  85. }
  86. }
  87. const fetchReceipt = async (invoiceId: string) => {
  88. if (!slug) return
  89. try {
  90. const receipt = await getInvoiceReceipt({ invoiceId, slug })
  91. if (receipt?.receipt_pdf) window.open(receipt.receipt_pdf, '_blank')
  92. } catch (error: any) {
  93. toast.error(`Failed to fetch receipt: ${error.message}`)
  94. }
  95. }
  96. if (selectedOrganization && isPartnerBilledOrganization) {
  97. return (
  98. <PartnerManagedResource
  99. managedBy={selectedOrganization?.managed_by}
  100. resource="Invoices"
  101. cta={getPartnerManagedResourceCta(selectedOrganization)}
  102. />
  103. )
  104. }
  105. // Handle loading state faded text for table headers
  106. const tableHeadClassName =
  107. isLoading || invoices.length === 0 ? 'text-foreground-muted' : undefined
  108. return (
  109. <Card>
  110. <Table>
  111. <TableHeader>
  112. <TableRow>
  113. {invoices.length > 0 && (
  114. <TableHead className="w-2">
  115. <span className="sr-only">Icon</span>
  116. </TableHead>
  117. )}
  118. <TableHead className={cn(tableHeadClassName)}>Date</TableHead>
  119. <TableHead className={cn(tableHeadClassName)}>Amount</TableHead>
  120. <TableHead className={cn(tableHeadClassName)}>Invoice number</TableHead>
  121. <TableHead className={cn(tableHeadClassName)}>Status</TableHead>
  122. <TableHead>
  123. <span className="sr-only">Actions</span>
  124. </TableHead>
  125. </TableRow>
  126. </TableHeader>
  127. <TableBody>
  128. {isLoading ? (
  129. new Array(6).fill(0).map((_, idx) => (
  130. <TableRow key={`loading-${idx}`}>
  131. <TableCell colSpan={invoices.length > 0 ? 6 : 5}>
  132. <ShimmeringLoader />
  133. </TableCell>
  134. </TableRow>
  135. ))
  136. ) : isError ? (
  137. <TableRow className="rounded-b">
  138. <TableCell
  139. colSpan={invoices.length > 0 ? 6 : 5}
  140. className="p-0! rounded-b! overflow-hidden"
  141. >
  142. <AlertError
  143. className="border-0 rounded-none"
  144. error={error}
  145. subject="Failed to retrieve invoices"
  146. />
  147. </TableCell>
  148. </TableRow>
  149. ) : invoices.length === 0 ? (
  150. <TableRow className="[&>td]:hover:bg-inherit">
  151. <TableCell colSpan={5} className="py-6">
  152. <p className="text-foreground-lighter">No invoices for this organization yet</p>
  153. </TableCell>
  154. </TableRow>
  155. ) : (
  156. <>
  157. {invoices.map((x) => {
  158. return (
  159. <TableRow key={x.id}>
  160. <TableCell className="w-2">
  161. <FileText aria-hidden="true" size={16} className="text-foreground-muted" />
  162. </TableCell>
  163. <TableCell>
  164. <p>{dayjs(x.period_end * 1000).format('MMM DD, YYYY')}</p>
  165. </TableCell>
  166. <TableCell translate="no">
  167. <p>{formatCurrency(x.amount_due / 100)}</p>
  168. </TableCell>
  169. <TableCell>
  170. <p className="font-mono text-foreground-light">{x.number}</p>
  171. </TableCell>
  172. <TableCell>
  173. <InvoiceStatusBadge
  174. status={x.status as InvoiceStatus}
  175. paymentAttempted={x.payment_attempted}
  176. paymentProcessing={x.payment_is_processing}
  177. />
  178. </TableCell>
  179. <TableCell className="text-right">
  180. <div className="flex items-center justify-end space-x-2">
  181. {x.amount_due > 0 &&
  182. !x.payment_is_processing &&
  183. [
  184. InvoiceStatus.UNCOLLECTIBLE,
  185. InvoiceStatus.OPEN,
  186. InvoiceStatus.ISSUED,
  187. ].includes(x.status as InvoiceStatus) && (
  188. <InvoicePayButton slug={slug} invoiceId={x.id} />
  189. )}
  190. <ButtonTooltip
  191. type="outline"
  192. className="w-7"
  193. icon={<ScrollText size={16} strokeWidth={1.5} />}
  194. onClick={() => fetchInvoice(x.id)}
  195. tooltip={{ content: { side: 'bottom', text: 'Download invoice' } }}
  196. />
  197. {x.status === InvoiceStatus.PAID && x.amount_due > 0 && (
  198. <ButtonTooltip
  199. type="outline"
  200. className="w-7"
  201. icon={<Receipt size={16} strokeWidth={1.5} />}
  202. onClick={() => fetchReceipt(x.id)}
  203. tooltip={{ content: { side: 'bottom', text: 'Download receipt' } }}
  204. />
  205. )}
  206. </div>
  207. </TableCell>
  208. </TableRow>
  209. )
  210. })}
  211. </>
  212. )}
  213. </TableBody>
  214. </Table>
  215. {invoices.length > 0 && (
  216. <CardFooter className="border-t p-4 flex items-center justify-between">
  217. <p className="text-foreground-muted text-sm">
  218. {isErrorCount
  219. ? 'Failed to retrieve total number of invoices'
  220. : typeof count === 'number'
  221. ? `Showing ${offset + 1} to ${offset + invoices.length} out of ${count} invoices`
  222. : `Showing ${offset + 1} to ${offset + invoices.length} invoices`}
  223. </p>
  224. <div className="flex items-center gap-x-2" aria-label="Pagination">
  225. <Button
  226. icon={<ChevronLeft />}
  227. aria-label="Previous page"
  228. type="default"
  229. size="tiny"
  230. disabled={page === 1}
  231. onClick={async () => setPage(page - 1)}
  232. />
  233. <Button
  234. icon={<ChevronRight />}
  235. aria-label="Next page"
  236. type="default"
  237. size="tiny"
  238. disabled={page * PAGE_LIMIT >= (count ?? 0)}
  239. onClick={async () => setPage(page + 1)}
  240. />
  241. </div>
  242. </CardFooter>
  243. )}
  244. </Card>
  245. )
  246. }