Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
pontusab committed Oct 27, 2024
1 parent 99c2676 commit aec9dca
Show file tree
Hide file tree
Showing 13 changed files with 399 additions and 38 deletions.
5 changes: 2 additions & 3 deletions apps/dashboard/src/actions/invoice/draft-invoice-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 18 additions & 3 deletions apps/dashboard/src/app/[locale]/(public)/i/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -55,8 +61,17 @@ export default async function Page({ params }: { params: { token: string } }) {
}

return (
<div className="flex justify-center items-center h-screen dotted-bg">
<HtmlTemplate {...invoice} />
<div className="flex flex-col justify-center items-center h-screen dotted-bg">
<div>
<CustomerHeader
name={invoice.customer_name || invoice.customer?.name}
website={invoice.customer?.website}
status={invoice.status}
/>
<HtmlTemplate {...invoice} />
</div>

<InvoiceToolbar customer={invoice.customer} />
</div>
);
} catch (error) {
Expand Down
31 changes: 31 additions & 0 deletions apps/dashboard/src/components/customer-heaader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex justify-between items-center mb-4">
<div className="flex items-center space-x-2">
<Avatar className="size-5">
{website && (
<AvatarImage
src={`https://img.logo.dev/${website}?token=pk_X-1ZO13GSgeOoUrIuJ6GMQ&size=60`}
alt={`${name} logo`}
/>
)}
<AvatarFallback className="text-[9px] font-medium">
{name?.[0]}
</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{name}</span>
</div>

<InvoiceStatus status={status} />
</div>
);
}
55 changes: 55 additions & 0 deletions apps/dashboard/src/components/invoice-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
className="fixed inset-x-0 bottom-2 flex justify-center"
initial={{ opacity: 0, filter: "blur(8px)", y: 0 }}
animate={{ opacity: 1, filter: "blur(0px)", y: -24 }}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
>
<div className="backdrop-filter backdrop-blur-lg dark:bg-[#1A1A1A]/80 bg-[#F6F6F3]/80 rounded-full px-4 py-3 h-10 flex items-center justify-center">
<Button variant="ghost" size="icon" className="rounded-full size-8">
<MdOutlineFileDownload className="size-4" />
</Button>

<Button variant="ghost" size="icon" className="rounded-full size-8">
<MdContentCopy />
</Button>

<Button
variant="ghost"
size="icon"
className="rounded-full size-8 relative"
>
<div className="rounded-full size-1 absolute bg-[#FFD02B] right-[3px] top-[3px] ring-2 ring-background">
<div className="absolute inset-0 rounded-full bg-[#FFD02B] animate-[ping_1s_ease-in-out_5]" />
<div className="absolute inset-0 rounded-full bg-[#FFD02B] animate-[pulse_1s_ease-in-out_5] opacity-75" />
<div className="absolute inset-0 rounded-full bg-[#FFD02B] animate-[pulse_1s_ease-in-out_5] opacity-50" />
</div>
<MdChatBubbleOutline />
</Button>

<InvoiceViewers customer={customer} />
</div>
</motion.div>
);
}
105 changes: 105 additions & 0 deletions apps/dashboard/src/components/invoice-viewers.tsx
Original file line number Diff line number Diff line change
@@ -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<User | null>(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 (
<AnimatedSizeContainer width>
<motion.div
className="flex items-center"
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
transition={{ duration: 0.3, ease: "easeInOut", delay: 0.5 }}
>
<Separator orientation="vertical" className="mr-3 ml-2 h-4" />

{currentUser && (
<div className="mr-2">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Avatar className="size-5 object-contain border border-border">
<AvatarImage src={currentUser.avatar_url || undefined} />
<AvatarFallback>
{currentUser.full_name?.[0]}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent sideOffset={20} className="text-xs px-2 py-1">
<p>Viewing right now</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}

<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Avatar className="size-5 object-contain border border-border">
{customer?.website && (
<AvatarImage
src={`https://img.logo.dev/${customer.website}?token=pk_X-1ZO13GSgeOoUrIuJ6GMQ&size=60`}
alt={`${customer.name} logo`}
/>
)}
<AvatarFallback className="text-[9px] font-medium">
{customer.name?.[0]}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent sideOffset={20} className="text-xs px-2 py-1">
<p>Last viewed 30m ago</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
</AnimatedSizeContainer>
);
}
11 changes: 7 additions & 4 deletions apps/dashboard/src/components/invoice/line-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -172,9 +173,6 @@ function LineItemRow({
name: `line_items.${index}.vat`,
});

const total =
(price || 0) * (quantity || 0) * (1 + (includeVAT ? (vat || 0) / 100 : 0));

return (
<Reorder.Item
className={`grid ${includeVAT ? "grid-cols-[1.5fr_15%_15%_6%_15%]" : "grid-cols-[1.5fr_15%_15%_15%]"} gap-4 items-end relative group mb-2 w-full`}
Expand Down Expand Up @@ -204,7 +202,12 @@ function LineItemRow({
<div className="text-right">
<span className="text-[11px] text-primary">
{formatAmount({
amount: total,
amount: calculateTotal({
price,
quantity,
vat,
includeVAT,
}),
currency,
})}
</span>
Expand Down
15 changes: 2 additions & 13 deletions apps/dashboard/src/components/invoice/summary.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion packages/invoice/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
51 changes: 40 additions & 11 deletions packages/invoice/src/templates/html/components/line-items.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { calculateTotal } from "../../../utils/calculate";
import { formatAmount } from "../../../utils/format";
import type { LineItem } from "../../types";
type Props = {
Expand All @@ -7,6 +8,7 @@ type Props = {
quantityLabel: string;
priceLabel: string;
totalLabel: string;
includeVAT?: boolean;
};

export function LineItems({
Expand All @@ -16,26 +18,53 @@ export function LineItems({
quantityLabel,
priceLabel,
totalLabel,
includeVAT = false,
}: Props) {
return (
<div className="mt-5">
<div className="flex border-b border-black pb-1 mb-1">
<div className="flex-3 text-xs font-medium">{descriptionLabel}</div>
<div className="flex-1 text-xs font-medium">{priceLabel}</div>
<div className="flex-[0.5] text-xs font-medium">{quantityLabel}</div>
<div className="flex-1 text-xs font-medium text-right">
<div
className={`grid ${includeVAT ? "grid-cols-[1.5fr_15%_15%_6%_15%]" : "grid-cols-[1.5fr_15%_15%_15%]"} gap-4 items-end relative group mb-2 w-full border-b border-black pb-1`}
>
<div className="text-[11px] text-[#878787] font-mono">
{descriptionLabel}
</div>

<div className="text-[11px] text-[#878787] font-mono">{priceLabel}</div>

<div className="text-[11px] text-[#878787] font-mono">
{quantityLabel}
</div>

{includeVAT && (
<div className="text-[11px] text-[#878787] font-mono">VAT</div>
)}

<div className="text-[11px] text-[#878787] font-mono text-right">
{totalLabel}
</div>
</div>

{lineItems.map((item, index) => (
<div key={`line-item-${index.toString()}`} className="flex py-1">
<div className="flex-3 text-xs">{item.name}</div>
<div className="flex-1 text-xs">
<div
key={`line-item-${index.toString()}`}
className={`grid ${includeVAT ? "grid-cols-[1.5fr_15%_15%_6%_15%]" : "grid-cols-[1.5fr_15%_15%_15%]"} gap-4 items-end relative group mb-2 w-full py-1`}
>
<div className="text-xs">{item.name}</div>
<div className="text-xs">
{formatAmount({ currency, amount: item.price })}
</div>
<div className="flex-[0.5] text-xs">{item.quantity}</div>
<div className="flex-1 text-xs text-right">
{formatAmount({ currency, amount: item.quantity * item.price })}
<div className="text-xs">{item.quantity}</div>
{includeVAT && <div className="text-xs">{item.vat}%</div>}
<div className="text-xs text-right">
{formatAmount({
currency,
amount: calculateTotal({
price: item.price,
quantity: item.quantity,
vat: item.vat,
includeVAT,
}),
})}
</div>
</div>
))}
Expand Down
Loading

0 comments on commit aec9dca

Please sign in to comment.