aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/page.tsx9
-rw-r--r--src/components/invoice-generator.tsx131
-rw-r--r--src/components/ui/button.tsx13
-rw-r--r--src/components/ui/input.tsx13
4 files changed, 163 insertions, 3 deletions
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 75f3c42..c71255f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,7 +1,10 @@
+import { InvoiceGenerator } from "@/components/invoice-generator";
+
export default function Home() {
return (
- <div>
- <h1>Free Invoice Generator</h1>
- </div>
+ <main className="space-y-4 p-2">
+ <h1 className="text-xl">Free Invoice Generator</h1>
+ <InvoiceGenerator />
+ </main>
);
}
diff --git a/src/components/invoice-generator.tsx b/src/components/invoice-generator.tsx
new file mode 100644
index 0000000..d1552c1
--- /dev/null
+++ b/src/components/invoice-generator.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { Invoice, InvoiceSchema } from "@/models/invoice";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useFieldArray, useForm, useWatch } from "react-hook-form";
+import { Input } from "./ui/input";
+import React from "react";
+import { cn } from "@/utils/cn";
+import { Button } from "./ui/button";
+
+const defaultItem = {
+ description: "",
+ quantity: 1,
+ rate: 0,
+};
+
+const defaultValues: Invoice = {
+ invoice_number: 0,
+ from: "",
+ line_items: [defaultItem],
+ currency: "USD",
+};
+
+export const InvoiceGenerator = () => {
+ const {
+ register,
+ handleSubmit,
+ control,
+ watch,
+ formState: { errors },
+ } = useForm<Invoice>({
+ resolver: zodResolver(InvoiceSchema),
+ defaultValues,
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: "line_items",
+ });
+ const lineItems = useWatch({ control, name: "line_items" });
+ const currency = watch("currency", "USD");
+
+ return (
+ <form className="max-w-5xl rounded-sm border p-4 shadow-lg">
+ <div className="flex justify-between gap-4">
+ <section className="grid auto-rows-min grid-cols-2 gap-2">
+ <Input
+ {...register("from")}
+ placeholder="Who is this from?"
+ className="col-span-2"
+ />
+ {errors["from"] && (
+ <p className="col-span-2">{errors["from"].message ?? "Error"}</p>
+ )}
+ <div className="grid">
+ <label>Bill To</label>
+ <Input {...register("bill_to")} placeholder="Who is this for?" />
+ </div>
+ <div className="grid">
+ <label>Ship To</label>
+ <Input {...register("ship_to")} placeholder="(optional)" />
+ </div>
+ </section>
+ <section className="grid auto-rows-min grid-cols-2 gap-2">
+ <label>Invoice #</label>
+ <Input {...register("invoice_number", { valueAsNumber: true })} />
+ <label>Date</label>
+ <Input {...register("date")} type="date" />
+ <label>Due Date</label>
+ <Input {...register("due_date")} type="date" />
+ <label>PO number</label>
+ <Input {...register("po_number", { valueAsNumber: true })} />
+ </section>
+ </div>
+ <h2>Your Items</h2>
+ <div
+ className={cn(
+ "bg-foreground text-background rounded-sm px-2 py-1",
+ "mb-2 grid grid-cols-11 gap-2",
+ )}
+ >
+ <p className="col-span-7">Item</p>
+ <p className="pl-2">Quantity</p>
+ <p className="pl-2">Rate</p>
+ <p className="text-center">Amount</p>
+ <p className="pr-2 text-right">Actions</p>
+ </div>
+ <section className="grid grid-cols-11 gap-2">
+ {fields.map((f, ind) => (
+ <React.Fragment key={f.id}>
+ <Input
+ {...register(`line_items.${ind}.description`)}
+ placeholder="Description of item/service..."
+ className="col-span-7"
+ />
+ <Input
+ {...register(`line_items.${ind}.quantity`, {
+ valueAsNumber: true,
+ })}
+ />
+ <Input
+ {...register(`line_items.${ind}.rate`, { valueAsNumber: true })}
+ />
+ <p className="m-auto text-center">
+ {(lineItems[ind]?.rate ?? 0) * (lineItems[ind]?.quantity ?? 0)}{" "}
+ {currency}
+ </p>
+ <Button
+ type="button"
+ onClick={() => remove(ind)}
+ className="m-auto size-fit"
+ >
+ x
+ </Button>
+ </React.Fragment>
+ ))}
+ <Button type="button" onClick={() => append(defaultItem)}>
+ + Item
+ </Button>
+ </section>
+ <section className="text-right text-xl">
+ Subtotal:{" "}
+ {lineItems.reduce(
+ (acc, cur) => acc + (cur.rate ?? 0) * (cur.quantity ?? 0),
+ 0,
+ )}{" "}
+ {currency}
+ </section>
+ </form>
+ );
+};
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..32b9aac
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/utils/cn";
+
+export const Button = ({
+ className,
+ ...props
+}: React.ComponentPropsWithRef<"button">) => {
+ return (
+ <button
+ {...props}
+ className={cn("rounded-sm border px-2", "cursor-pointer", className)}
+ />
+ );
+};
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..11bcd12
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/utils/cn";
+
+export const Input = ({
+ className,
+ ...props
+}: React.ComponentPropsWithRef<"input">) => {
+ return (
+ <input
+ {...props}
+ className={cn("rounded-sm border px-2 py-1", className)}
+ />
+ );
+};