From aec9dca883162f9054b04d1202872c0036f67fd4 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Sun, 27 Oct 2024 11:21:47 +0100 Subject: [PATCH] wip --- .../actions/invoice/draft-invoice-action.ts | 5 +- .../app/[locale]/(public)/i/[token]/page.tsx | 21 +++- .../src/components/customer-heaader.tsx | 31 ++++++ .../src/components/invoice-toolbar.tsx | 55 +++++++++ .../src/components/invoice-viewers.tsx | 105 ++++++++++++++++++ .../src/components/invoice/line-items.tsx | 11 +- .../src/components/invoice/summary.tsx | 15 +-- packages/invoice/package.json | 3 +- .../templates/html/components/line-items.tsx | 51 +++++++-- .../src/templates/html/components/summary.tsx | 80 +++++++++++++ packages/invoice/src/templates/html/index.tsx | 27 ++++- packages/invoice/src/utils/calculate.ts | 31 ++++++ packages/supabase/src/queries/index.ts | 2 +- 13 files changed, 399 insertions(+), 38 deletions(-) create mode 100644 apps/dashboard/src/components/customer-heaader.tsx create mode 100644 apps/dashboard/src/components/invoice-toolbar.tsx create mode 100644 apps/dashboard/src/components/invoice-viewers.tsx create mode 100644 packages/invoice/src/templates/html/components/summary.tsx create mode 100644 packages/invoice/src/utils/calculate.ts diff --git a/apps/dashboard/src/actions/invoice/draft-invoice-action.ts b/apps/dashboard/src/actions/invoice/draft-invoice-action.ts index a490c545d..2e81488d2 100644 --- a/apps/dashboard/src/actions/invoice/draft-invoice-action.ts +++ b/apps/dashboard/src/actions/invoice/draft-invoice-action.ts @@ -17,9 +17,8 @@ export const draftInvoiceAction = authActionClient }) => { const teamId = user.team_id; - // Generate token if customer_id is not provided because it's a new invoice - // We use upsert so we don't have to check if the invoice already exists - const token = !input.customer_id && (await generateToken(id)); + // TODO: Only generate token if status is pending and fix draft link + const token = await generateToken(id); const { payment_details, from_details, ...restTemplate } = template; diff --git a/apps/dashboard/src/app/[locale]/(public)/i/[token]/page.tsx b/apps/dashboard/src/app/[locale]/(public)/i/[token]/page.tsx index 1df4b2bf0..618f64550 100644 --- a/apps/dashboard/src/app/[locale]/(public)/i/[token]/page.tsx +++ b/apps/dashboard/src/app/[locale]/(public)/i/[token]/page.tsx @@ -1,3 +1,5 @@ +import CustomerHeader from "@/components/customer-heaader"; +import InvoiceToolbar from "@/components/invoice-toolbar"; import { HtmlTemplate } from "@midday/invoice/templates/html"; import { verify } from "@midday/invoice/token"; import { getInvoiceQuery } from "@midday/supabase/queries"; @@ -43,7 +45,11 @@ export async function generateMetadata({ } } -export default async function Page({ params }: { params: { token: string } }) { +type Props = { + params: { token: string }; +}; + +export default async function Page({ params }: Props) { const supabase = createClient({ admin: true }); try { @@ -55,8 +61,17 @@ export default async function Page({ params }: { params: { token: string } }) { } return ( -
- +
+
+ + +
+ +
); } catch (error) { diff --git a/apps/dashboard/src/components/customer-heaader.tsx b/apps/dashboard/src/components/customer-heaader.tsx new file mode 100644 index 000000000..d5421082b --- /dev/null +++ b/apps/dashboard/src/components/customer-heaader.tsx @@ -0,0 +1,31 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@midday/ui/avatar"; +import { InvoiceStatus } from "./invoice-status"; + +type Props = { + name: string; + website: string; + status: "overdue" | "paid" | "unpaid" | "draft" | "canceled"; +}; + +export default function CustomerHeader({ name, website, status }: Props) { + return ( +
+
+ + {website && ( + + )} + + {name?.[0]} + + + {name} +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/invoice-toolbar.tsx b/apps/dashboard/src/components/invoice-toolbar.tsx new file mode 100644 index 000000000..78f09695a --- /dev/null +++ b/apps/dashboard/src/components/invoice-toolbar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Button } from "@midday/ui/button"; +import { motion } from "framer-motion"; +import { + MdChatBubbleOutline, + MdContentCopy, + MdOutlineFileDownload, +} from "react-icons/md"; +import { InvoiceViewers } from "./invoice-viewers"; + +export type Customer = { + name: string; + website?: string; +}; + +type Props = { + customer: Customer; +}; + +export default function InvoiceToolbar({ customer }: Props) { + return ( + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/invoice-viewers.tsx b/apps/dashboard/src/components/invoice-viewers.tsx new file mode 100644 index 000000000..1db7591e2 --- /dev/null +++ b/apps/dashboard/src/components/invoice-viewers.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { createClient } from "@midday/supabase/client"; +import { AnimatedSizeContainer } from "@midday/ui/animated-size-container"; +import { Avatar, AvatarFallback, AvatarImage } from "@midday/ui/avatar"; +import { Separator } from "@midday/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@midday/ui/tooltip"; +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; +import type { Customer } from "./invoice-toolbar"; + +interface User { + id: string; + avatar_url: string | null; + full_name: string | null; +} + +type Props = { + customer: Customer; +}; + +export function InvoiceViewers({ customer }: Props) { + const [currentUser, setCurrentUser] = useState(null); + const supabase = createClient(); + + useEffect(() => { + async function fetchCurrentUser() { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) { + setCurrentUser({ + id: user.id, + avatar_url: user.user_metadata.avatar_url, + full_name: user.user_metadata.full_name, + }); + } + } + + fetchCurrentUser(); + }, []); + + if (!currentUser) { + return null; + } + + return ( + + + + + {currentUser && ( +
+ + + + + + + {currentUser.full_name?.[0]} + + + + +

Viewing right now

+
+
+
+
+ )} + + + + + + {customer?.website && ( + + )} + + {customer.name?.[0]} + + + + +

Last viewed 30m ago

+
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/invoice/line-items.tsx b/apps/dashboard/src/components/invoice/line-items.tsx index f00bef5af..08f2d9769 100644 --- a/apps/dashboard/src/components/invoice/line-items.tsx +++ b/apps/dashboard/src/components/invoice/line-items.tsx @@ -3,6 +3,7 @@ import type { InvoiceFormValues } from "@/actions/invoice/schema"; import { updateInvoiceTemplateAction } from "@/actions/invoice/update-invoice-template-action"; import { formatAmount } from "@/utils/format"; +import { calculateTotal } from "@midday/invoice/calculate"; import { Button } from "@midday/ui/button"; import { Icons } from "@midday/ui/icons"; import { Reorder, useDragControls } from "framer-motion"; @@ -172,9 +173,6 @@ function LineItemRow({ name: `line_items.${index}.vat`, }); - const total = - (price || 0) * (quantity || 0) * (1 + (includeVAT ? (vat || 0) / 100 : 0)); - return ( {formatAmount({ - amount: total, + amount: calculateTotal({ + price, + quantity, + vat, + includeVAT, + }), currency, })} diff --git a/apps/dashboard/src/components/invoice/summary.tsx b/apps/dashboard/src/components/invoice/summary.tsx index 0b31b2a7c..27073ef01 100644 --- a/apps/dashboard/src/components/invoice/summary.tsx +++ b/apps/dashboard/src/components/invoice/summary.tsx @@ -1,5 +1,6 @@ import type { InvoiceFormValues } from "@/actions/invoice/schema"; import { updateInvoiceTemplateAction } from "@/actions/invoice/update-invoice-template-action"; +import { calculateTotals } from "@midday/invoice/calculate"; import { useAction } from "next-safe-action/hooks"; import { useMemo } from "react"; import { useFormContext, useWatch } from "react-hook-form"; @@ -37,19 +38,7 @@ export function Summary() { const updateInvoiceTemplate = useAction(updateInvoiceTemplateAction); - const { totalAmount, totalVAT } = useMemo(() => { - return lineItems.reduce( - (acc, item) => { - const itemTotal = (item.price || 0) * (item.quantity || 0); - const itemVAT = (itemTotal * (item.vat || 0)) / 100; - return { - totalAmount: acc.totalAmount + itemTotal, - totalVAT: acc.totalVAT + itemVAT, - }; - }, - { totalAmount: 0, totalVAT: 0 }, - ); - }, [lineItems]); + const { totalAmount, totalVAT } = calculateTotals(lineItems); const totalTax = includeTax ? (totalAmount * (taxRate || 0)) / 100 : 0; diff --git a/packages/invoice/package.json b/packages/invoice/package.json index 2612ec1e6..82d04de9d 100644 --- a/packages/invoice/package.json +++ b/packages/invoice/package.json @@ -14,7 +14,8 @@ "./number": "./src/utils/number.ts", "./templates/html": "./src/templates/html/index.tsx", "./templates/pdf": "./src/templates/pdf/index.tsx", - "./editor": "./src/editor/index.tsx" + "./editor": "./src/editor/index.tsx", + "./calculate": "./src/utils/calculate.ts" }, "dependencies": { "@midday/ui": "workspace:*", diff --git a/packages/invoice/src/templates/html/components/line-items.tsx b/packages/invoice/src/templates/html/components/line-items.tsx index e0c390724..cb8981f8d 100644 --- a/packages/invoice/src/templates/html/components/line-items.tsx +++ b/packages/invoice/src/templates/html/components/line-items.tsx @@ -1,3 +1,4 @@ +import { calculateTotal } from "../../../utils/calculate"; import { formatAmount } from "../../../utils/format"; import type { LineItem } from "../../types"; type Props = { @@ -7,6 +8,7 @@ type Props = { quantityLabel: string; priceLabel: string; totalLabel: string; + includeVAT?: boolean; }; export function LineItems({ @@ -16,26 +18,53 @@ export function LineItems({ quantityLabel, priceLabel, totalLabel, + includeVAT = false, }: Props) { return (
-
-
{descriptionLabel}
-
{priceLabel}
-
{quantityLabel}
-
+
+
+ {descriptionLabel} +
+ +
{priceLabel}
+ +
+ {quantityLabel} +
+ + {includeVAT && ( +
VAT
+ )} + +
{totalLabel}
+ {lineItems.map((item, index) => ( -
-
{item.name}
-
+
+
{item.name}
+
{formatAmount({ currency, amount: item.price })}
-
{item.quantity}
-
- {formatAmount({ currency, amount: item.quantity * item.price })} +
{item.quantity}
+ {includeVAT &&
{item.vat}%
} +
+ {formatAmount({ + currency, + amount: calculateTotal({ + price: item.price, + quantity: item.quantity, + vat: item.vat, + includeVAT, + }), + })}
))} diff --git a/packages/invoice/src/templates/html/components/summary.tsx b/packages/invoice/src/templates/html/components/summary.tsx new file mode 100644 index 000000000..19fbadbf2 --- /dev/null +++ b/packages/invoice/src/templates/html/components/summary.tsx @@ -0,0 +1,80 @@ +import { calculateTotals } from "../../../utils/calculate"; +import type { LineItem } from "../../types"; + +type Props = { + includeVAT: boolean; + includeTax: boolean; + taxRate: number; + currency: string; + vatLabel: string; + taxLabel: string; + totalLabel: string; + lineItems: LineItem[]; +}; + +export function Summary({ + includeVAT, + includeTax, + taxRate, + currency, + vatLabel, + taxLabel, + totalLabel, + lineItems, +}: Props) { + const { totalAmount, totalVAT } = calculateTotals(lineItems); + + const totalTax = includeTax ? (totalAmount * (taxRate || 0)) / 100 : 0; + + const total = totalAmount + totalVAT + totalTax; + + return ( +
+ {includeVAT && ( +
+ + {vatLabel} + + + {new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(totalVAT)} + +
+ )} + + {includeTax && ( +
+ + {taxLabel} + + + {new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(totalTax)} + +
+ )} + +
+ + {totalLabel} + + + {new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(total)} + +
+
+ ); +} diff --git a/packages/invoice/src/templates/html/index.tsx b/packages/invoice/src/templates/html/index.tsx index 7546a9da1..4daa760dc 100644 --- a/packages/invoice/src/templates/html/index.tsx +++ b/packages/invoice/src/templates/html/index.tsx @@ -1,11 +1,10 @@ import { ScrollArea } from "@midday/ui/scroll-area"; import type { TemplateProps } from "../types"; -import { CustomerDetails } from "./components/customer-details"; import { EditorContent } from "./components/editor-content"; -import { FromDetails } from "./components/from-details"; import { LineItems } from "./components/line-items"; import { Logo } from "./components/logo"; import { Meta } from "./components/meta"; +import { Summary } from "./components/summary"; export function HtmlTemplate({ invoice_number, @@ -69,8 +68,32 @@ export function HtmlTemplate({ lineItems={line_items} currency={currency} descriptionLabel={template.description_label} + quantityLabel={template.quantity_label} + priceLabel={template.price_label} + totalLabel={template.total_label} + includeVAT={template.include_vat} />
+ +
+ +
+ +
+
+ + +
+
); diff --git a/packages/invoice/src/utils/calculate.ts b/packages/invoice/src/utils/calculate.ts new file mode 100644 index 000000000..7a43e9e79 --- /dev/null +++ b/packages/invoice/src/utils/calculate.ts @@ -0,0 +1,31 @@ +export function calculateTotal({ + price, + quantity, + vat, + includeVAT, +}: { + price: number; + quantity: number; + vat?: number; + includeVAT: boolean; +}): number { + const baseTotal = price * quantity; + const vatMultiplier = includeVAT ? 1 + (vat || 0) / 100 : 1; + return baseTotal * vatMultiplier; +} + +export function calculateTotals( + lineItems: Array<{ price?: number; quantity?: number; vat?: number }>, +) { + return lineItems.reduce( + (acc, item) => { + const itemTotal = (item.price || 0) * (item.quantity || 0); + const itemVAT = (itemTotal * (item.vat || 0)) / 100; + return { + totalAmount: acc.totalAmount + itemTotal, + totalVAT: acc.totalVAT + itemVAT, + }; + }, + { totalAmount: 0, totalVAT: 0 }, + ); +} diff --git a/packages/supabase/src/queries/index.ts b/packages/supabase/src/queries/index.ts index 4fe645932..01b46c1a8 100644 --- a/packages/supabase/src/queries/index.ts +++ b/packages/supabase/src/queries/index.ts @@ -1281,7 +1281,7 @@ export async function getInvoiceNumberQuery(supabase: Client, teamId: string) { export async function getInvoiceQuery(supabase: Client, id: string) { return supabase .from("invoices") - .select("*, customer:customer_id(name), team:team_id(name)") + .select("*, customer:customer_id(name, website), team:team_id(name)") .eq("id", id) .single(); }