|
| 1 | +import { Document, Head, Page, Spacer } from "@htmldocs/react"; |
| 2 | +import clsx from "clsx"; |
| 3 | +import "~/index.css"; |
| 4 | + |
| 5 | +const tableHeaderStyle = |
| 6 | + "text-sm font-medium text-gray-900 py-2 whitespace-nowrap"; |
| 7 | + |
| 8 | +interface BilledTo { |
| 9 | + name: string; |
| 10 | + address: string; |
| 11 | + city: string; |
| 12 | + state: string; |
| 13 | + zip: string; |
| 14 | + phone: string; |
| 15 | +} |
| 16 | + |
| 17 | +interface YourCompany { |
| 18 | + name: string; |
| 19 | + address: string; |
| 20 | + city: string; |
| 21 | + state: string; |
| 22 | + zip: string; |
| 23 | + taxId: string; |
| 24 | + phone: string; |
| 25 | + email: string; |
| 26 | +} |
| 27 | + |
| 28 | +interface Service { |
| 29 | + name: string; |
| 30 | + description?: string; |
| 31 | + quantity: number; |
| 32 | + rate: number; |
| 33 | +} |
| 34 | + |
| 35 | +export interface InvoiceProps { |
| 36 | + billedTo: BilledTo; |
| 37 | + yourCompany: YourCompany; |
| 38 | + services: Service[]; |
| 39 | +} |
| 40 | + |
| 41 | +function Invoice({ billedTo, yourCompany, services }: InvoiceProps) { |
| 42 | + const issueDate = new Date().toLocaleDateString("en-US", { |
| 43 | + day: "2-digit", |
| 44 | + month: "short", |
| 45 | + year: "numeric", |
| 46 | + }); |
| 47 | + // 7 days from now |
| 48 | + const dueDate = new Date( |
| 49 | + Date.now() + 7 * 24 * 60 * 60 * 1000 |
| 50 | + ).toLocaleDateString("en-US", { |
| 51 | + day: "2-digit", |
| 52 | + month: "short", |
| 53 | + year: "numeric", |
| 54 | + }); |
| 55 | + |
| 56 | + const subtotal = services.reduce( |
| 57 | + (acc, service) => acc + service.quantity * service.rate, |
| 58 | + 0 |
| 59 | + ); |
| 60 | + const taxRate = 0.12; |
| 61 | + const tax = subtotal * taxRate; |
| 62 | + const total = subtotal + tax; |
| 63 | + |
| 64 | + return ( |
| 65 | + <Document size="A4" orientation="portrait" margin="0.5in"> |
| 66 | + <Head> |
| 67 | + <title>Invoice</title> |
| 68 | + <link rel="preconnect" href="https://rsms.me/" /> |
| 69 | + <link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> |
| 70 | + <style> |
| 71 | + {` |
| 72 | + :root { |
| 73 | + font-family: Inter, sans-serif; |
| 74 | + font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */ |
| 75 | + } |
| 76 | + @supports (font-variation-settings: normal) { |
| 77 | + :root { font-family: InterVariable, sans-serif; } |
| 78 | + } |
| 79 | + `} |
| 80 | + </style> |
| 81 | + </Head> |
| 82 | + <Page className="flex flex-col justify-between"> |
| 83 | + <div id="invoice_body"> |
| 84 | + <div |
| 85 | + id="document_header" |
| 86 | + className="flex flex-row items-center justify-between" |
| 87 | + > |
| 88 | + <div id="header_left" className="flex flex-col"> |
| 89 | + <div className="uppercase text-4xl font-medium mb-1">Invoice</div> |
| 90 | + <p className="text-gray-500">#AB2324-01</p> |
| 91 | + </div> |
| 92 | + <div id="header_right"> |
| 93 | + <img src="/static/logo.svg" alt="Company Logo" /> |
| 94 | + </div> |
| 95 | + </div> |
| 96 | + <Spacer height={48} /> |
| 97 | + <div className="flex flex-row justify-between border-y border-gray-300 divide-x divide-gray-300"> |
| 98 | + <div className="flex-1 flex flex-col justify-between p-4 pl-0"> |
| 99 | + <div> |
| 100 | + <h2 className="text-sm font-medium">Issued</h2> |
| 101 | + <p className="text-sm text-gray-500 font-medium">{issueDate}</p> |
| 102 | + </div> |
| 103 | + <div> |
| 104 | + <h2 className="text-sm font-medium mt-2">Due</h2> |
| 105 | + <p className="text-sm text-gray-500 font-medium">{dueDate}</p> |
| 106 | + </div> |
| 107 | + </div> |
| 108 | + <div className="flex-1 flex flex-col p-4"> |
| 109 | + <h2 className="text-sm font-medium">Billed To</h2> |
| 110 | + <p className="text-sm text-gray-500 font-medium"> |
| 111 | + {billedTo.name} |
| 112 | + </p> |
| 113 | + <p className="text-sm text-gray-500">{billedTo.address}</p> |
| 114 | + <p className="text-sm text-gray-500"> |
| 115 | + {billedTo.city}, {billedTo.state} {billedTo.zip} |
| 116 | + </p> |
| 117 | + <p className="text-sm text-gray-500">{billedTo.phone}</p> |
| 118 | + </div> |
| 119 | + <div className="flex-1 flex flex-col p-4 pr-0"> |
| 120 | + <h2 className="text-sm font-medium">From</h2> |
| 121 | + <p className="text-sm text-gray-500 font-semibold"> |
| 122 | + {yourCompany.name} |
| 123 | + </p> |
| 124 | + <p className="text-sm text-gray-500">{yourCompany.address}</p> |
| 125 | + <p className="text-sm text-gray-500"> |
| 126 | + {yourCompany.city}, {yourCompany.state} {yourCompany.zip} |
| 127 | + </p> |
| 128 | + <p className="text-sm text-gray-500"> |
| 129 | + TAX ID {yourCompany.taxId} |
| 130 | + </p> |
| 131 | + </div> |
| 132 | + </div> |
| 133 | + <Spacer height={32} /> |
| 134 | + <div className="flex flex-col mt-4"> |
| 135 | + <div className="overflow-x-auto"> |
| 136 | + <table className="min-w-full"> |
| 137 | + <thead className="border-b"> |
| 138 | + <tr> |
| 139 | + <th |
| 140 | + scope="col" |
| 141 | + className={clsx(tableHeaderStyle, "text-left")} |
| 142 | + > |
| 143 | + Service |
| 144 | + </th> |
| 145 | + <th |
| 146 | + scope="col" |
| 147 | + className={clsx(tableHeaderStyle, "pr-16 text-right")} |
| 148 | + > |
| 149 | + Qty |
| 150 | + </th> |
| 151 | + <th |
| 152 | + scope="col" |
| 153 | + className={clsx(tableHeaderStyle, "text-right")} |
| 154 | + > |
| 155 | + Rate |
| 156 | + </th> |
| 157 | + <th |
| 158 | + scope="col" |
| 159 | + className={clsx(tableHeaderStyle, "pl-16 text-right")} |
| 160 | + > |
| 161 | + Line total |
| 162 | + </th> |
| 163 | + </tr> |
| 164 | + </thead> |
| 165 | + <tbody> |
| 166 | + {services.map((service) => ( |
| 167 | + <TableRow service={service} /> |
| 168 | + ))} |
| 169 | + <tr className="border-b"></tr> |
| 170 | + <tr className="h-12"> |
| 171 | + <td className="w-full"></td> |
| 172 | + <td className="text-left font-medium text-sm whitespace-nowrap border-b"> |
| 173 | + Subtotal |
| 174 | + </td> |
| 175 | + <td className="border-b"></td> |
| 176 | + <td className="text-right text-sm text-gray-900 whitespace-nowrap border-b"> |
| 177 | + {subtotal.toLocaleString("en-US", { |
| 178 | + style: "currency", |
| 179 | + currency: "USD", |
| 180 | + })} |
| 181 | + </td> |
| 182 | + </tr> |
| 183 | + <tr className="h-12"> |
| 184 | + <td className="w-full"></td> |
| 185 | + <td className="text-left font-medium text-sm whitespace-nowrap border-b"> |
| 186 | + Tax ({taxRate * 100}%) |
| 187 | + </td> |
| 188 | + <td className="border-b"></td> |
| 189 | + <td className="text-right text-sm text-gray-900 whitespace-nowrap border-b"> |
| 190 | + {tax.toLocaleString("en-US", { |
| 191 | + style: "currency", |
| 192 | + currency: "USD", |
| 193 | + })} |
| 194 | + </td> |
| 195 | + </tr> |
| 196 | + <tr className="h-12"> |
| 197 | + <td className="w-full"></td> |
| 198 | + <td className="text-left font-medium text-sm whitespace-nowrap border-b"> |
| 199 | + Total |
| 200 | + </td> |
| 201 | + <td className="border-b"></td> |
| 202 | + <td className="text-right text-sm text-gray-900 whitespace-nowrap border-b"> |
| 203 | + {total.toLocaleString("en-US", { |
| 204 | + style: "currency", |
| 205 | + currency: "USD", |
| 206 | + })} |
| 207 | + </td> |
| 208 | + </tr> |
| 209 | + <tr className="h-12 text-purple-700"> |
| 210 | + <td className="w-full"></td> |
| 211 | + <td className="text-left font-medium text-sm whitespace-nowrap border-y-2 border-purple-700"> |
| 212 | + Amount due |
| 213 | + </td> |
| 214 | + <td className="border-y-2 border-purple-700"></td> |
| 215 | + <td className="text-right font-medium text-sm whitespace-nowrap border-y-2 border-purple-700"> |
| 216 | + {total.toLocaleString("en-US", { |
| 217 | + style: "currency", |
| 218 | + currency: "USD", |
| 219 | + })} |
| 220 | + </td> |
| 221 | + </tr> |
| 222 | + </tbody> |
| 223 | + </table> |
| 224 | + </div> |
| 225 | + </div> |
| 226 | + </div> |
| 227 | + <div id="footer"> |
| 228 | + <div className="flex flex-col pb-4 border-b"> |
| 229 | + <p className="text-sm font-medium">Thank you for your business!</p> |
| 230 | + <p className="flex items-center gap-2"> |
| 231 | + <svg |
| 232 | + width="16" |
| 233 | + height="16" |
| 234 | + viewBox="0 0 10 10" |
| 235 | + fill="none" |
| 236 | + xmlns="http://www.w3.org/2000/svg" |
| 237 | + > |
| 238 | + <path |
| 239 | + fillRule="evenodd" |
| 240 | + clipRule="evenodd" |
| 241 | + d="M2 0C0.895431 0 0 0.89543 0 2V8C0 9.10457 0.89543 10 2 10H8C9.10457 10 10 9.10457 10 8V2C10 0.895431 9.10457 0 8 0H2ZM4.72221 2.95508C4.72221 2.7825 4.58145 2.64014 4.41071 2.66555C3.33092 2.82592 2.5 3.80797 2.5 4.99549V7.01758C2.5 7.19016 2.63992 7.33008 2.8125 7.33008H4.40971C4.58229 7.33008 4.72221 7.19016 4.72221 7.01758V5.6021C4.72221 5.42952 4.58229 5.2896 4.40971 5.2896H3.61115V4.95345C3.61115 4.41687 3.95035 3.96422 4.41422 3.82285C4.57924 3.77249 4.72221 3.63715 4.72221 3.4645V2.95508ZM7.5 2.95508C7.5 2.7825 7.35924 2.64014 7.18849 2.66555C6.1087 2.82592 5.27779 3.80797 5.27779 4.99549V7.01758C5.27779 7.19016 5.41771 7.33008 5.59029 7.33008H7.1875C7.36008 7.33008 7.5 7.19016 7.5 7.01758V5.6021C7.5 5.42952 7.36008 5.2896 7.1875 5.2896H6.38885V4.95345C6.38885 4.41695 6.72813 3.96422 7.19193 3.82285C7.35703 3.77249 7.5 3.63715 7.5 3.4645V2.95508Z" |
| 242 | + fill="#8B919E" |
| 243 | + /> |
| 244 | + </svg> |
| 245 | + <span className="text-sm text-gray-500"> |
| 246 | + Please pay within 15 days of receiving this invoice. |
| 247 | + </span> |
| 248 | + </p> |
| 249 | + </div> |
| 250 | + <Spacer height={36} /> |
| 251 | + <div className="flex justify-between text-sm text-gray-500"> |
| 252 | + {yourCompany.name} © {new Date().getFullYear()} |
| 253 | + <div className="flex items-center gap-8"> |
| 254 | + <p className="text-sm">{yourCompany.phone}</p> |
| 255 | + <p className="text-sm">{yourCompany.email}</p> |
| 256 | + </div> |
| 257 | + </div> |
| 258 | + </div> |
| 259 | + </Page> |
| 260 | + </Document> |
| 261 | + ); |
| 262 | +} |
| 263 | + |
| 264 | +interface TableRowProps { |
| 265 | + service: Service; |
| 266 | +} |
| 267 | + |
| 268 | +const TableRow = ({ service }: TableRowProps) => { |
| 269 | + const total = service.quantity * service.rate; |
| 270 | + const cellStyle = "text-sm text-gray-900 font-light py-4 whitespace-nowrap"; |
| 271 | + const detailStyle = |
| 272 | + "whitespace-nowrap align-top overflow-hidden overflow-ellipsis"; |
| 273 | + |
| 274 | + return ( |
| 275 | + <tr> |
| 276 | + <td className={clsx(cellStyle, "w-full")}> |
| 277 | + <div className="flex flex-col gap-1"> |
| 278 | + <span className="font-medium">{service.name}</span> |
| 279 | + <span className="text-gray-500">{service.description}</span> |
| 280 | + </div> |
| 281 | + </td> |
| 282 | + <td className={clsx(cellStyle, detailStyle, "text-left")}> |
| 283 | + {service.quantity} |
| 284 | + </td> |
| 285 | + <td className={clsx(cellStyle, detailStyle, "text-right")}> |
| 286 | + {service.rate.toLocaleString("en-US", { |
| 287 | + style: "currency", |
| 288 | + currency: "USD", |
| 289 | + })} |
| 290 | + </td> |
| 291 | + <td className={clsx(cellStyle, detailStyle, "text-right")}> |
| 292 | + {total.toLocaleString("en-US", { style: "currency", currency: "USD" })} |
| 293 | + </td> |
| 294 | + </tr> |
| 295 | + ); |
| 296 | +}; |
| 297 | + |
| 298 | +Invoice.PreviewProps = { |
| 299 | + billedTo: { |
| 300 | + name: "John Doe", |
| 301 | + address: "123 Elm Street", |
| 302 | + city: "Anytown", |
| 303 | + state: "CA", |
| 304 | + zip: "12345", |
| 305 | + phone: "123-456-7890", |
| 306 | + }, |
| 307 | + yourCompany: { |
| 308 | + name: "Your Company", |
| 309 | + address: "456 Banana Rd.", |
| 310 | + city: "San Francisco", |
| 311 | + state: "CA", |
| 312 | + zip: "94107", |
| 313 | + taxId: "00XXXXX1234X0XX", |
| 314 | + phone: "123-456-7890", |
| 315 | + |
| 316 | + }, |
| 317 | + services: [ |
| 318 | + { |
| 319 | + name: "Design", |
| 320 | + description: "Description", |
| 321 | + quantity: 1, |
| 322 | + rate: 1000, |
| 323 | + }, |
| 324 | + { |
| 325 | + name: "Development", |
| 326 | + description: "Description", |
| 327 | + quantity: 2, |
| 328 | + rate: 1200, |
| 329 | + }, |
| 330 | + ], |
| 331 | +}; |
| 332 | + |
| 333 | +Invoice.documentId = "invoice" |
| 334 | + |
| 335 | +export default Invoice; |
0 commit comments