Skip to content

Commit 2b0ae65

Browse files
authored
Merge pull request #3438 from Dokploy/feat/add-invoices-billing
Feat/add invoices billing
2 parents 068deec + 2acaaed commit 2b0ae65

File tree

8 files changed

+390
-33
lines changed

8 files changed

+390
-33
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { CreditCard, FileText } from "lucide-react";
2+
import Link from "next/link";
3+
import { useRouter } from "next/router";
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from "@/components/ui/card";
11+
import { cn } from "@/lib/utils";
12+
import { ShowInvoices } from "./show-invoices";
13+
14+
const navigationItems = [
15+
{
16+
name: "Subscription",
17+
href: "/dashboard/settings/billing",
18+
icon: CreditCard,
19+
},
20+
{
21+
name: "Invoices",
22+
href: "/dashboard/settings/invoices",
23+
icon: FileText,
24+
},
25+
];
26+
27+
export const ShowBillingInvoices = () => {
28+
const router = useRouter();
29+
30+
return (
31+
<div className="w-full">
32+
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
33+
<div className="rounded-xl bg-background shadow-md">
34+
<CardHeader>
35+
<CardTitle className="text-xl flex flex-row gap-2">
36+
<CreditCard className="size-6 text-muted-foreground self-center" />
37+
Billing
38+
</CardTitle>
39+
<CardDescription>
40+
Manage your subscription and invoices
41+
</CardDescription>
42+
</CardHeader>
43+
<CardContent className="space-y-4 py-4 border-t">
44+
<nav className="flex space-x-2 border-b">
45+
{navigationItems.map((item) => {
46+
const Icon = item.icon;
47+
const isActive = router.pathname === item.href;
48+
return (
49+
<Link
50+
key={item.name}
51+
href={item.href}
52+
className={cn(
53+
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
54+
isActive
55+
? "border-primary text-primary"
56+
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
57+
)}
58+
>
59+
<Icon className="h-4 w-4" />
60+
{item.name}
61+
</Link>
62+
);
63+
})}
64+
</nav>
65+
66+
<div className="mt-6">
67+
<ShowInvoices />
68+
</div>
69+
</CardContent>
70+
</div>
71+
</Card>
72+
</div>
73+
);
74+
};

apps/dokploy/components/dashboard/settings/billing/show-billing.tsx

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import {
44
AlertTriangle,
55
CheckIcon,
66
CreditCard,
7+
FileText,
78
Loader2,
89
MinusIcon,
910
PlusIcon,
1011
} from "lucide-react";
1112
import Link from "next/link";
13+
import { useRouter } from "next/router";
1214
import { useState } from "react";
1315
import { Badge } from "@/components/ui/badge";
1416
import { Button } from "@/components/ui/button";
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
3739
if (count <= 1) return 4.5;
3840
return count * 3.5;
3941
};
42+
43+
const navigationItems = [
44+
{
45+
name: "Subscription",
46+
href: "/dashboard/settings/billing",
47+
icon: CreditCard,
48+
},
49+
{
50+
name: "Invoices",
51+
href: "/dashboard/settings/invoices",
52+
icon: FileText,
53+
},
54+
];
55+
4056
export const ShowBilling = () => {
57+
const router = useRouter();
4158
const { data: servers } = api.server.count.useQuery();
4259
const { data: admin } = api.user.get.useQuery();
4360
const { data, isLoading } = api.stripe.getProducts.useQuery();
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
7693

7794
return (
7895
<div className="w-full">
79-
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
80-
<div className="rounded-xl bg-background shadow-md ">
81-
<CardHeader className="">
96+
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
97+
<div className="rounded-xl bg-background shadow-md">
98+
<CardHeader>
8299
<CardTitle className="text-xl flex flex-row gap-2">
83100
<CreditCard className="size-6 text-muted-foreground self-center" />
84101
Billing
85102
</CardTitle>
86-
<CardDescription>Manage your subscription</CardDescription>
103+
<CardDescription>
104+
Manage your subscription and invoices
105+
</CardDescription>
87106
</CardHeader>
88-
<CardContent className="space-y-2 py-8 border-t">
89-
<div className="flex flex-col gap-4 w-full">
107+
<CardContent className="space-y-4 py-4 border-t">
108+
<nav className="flex space-x-2 border-b">
109+
{navigationItems.map((item) => {
110+
const Icon = item.icon;
111+
const isActive = router.pathname === item.href;
112+
return (
113+
<Link
114+
key={item.name}
115+
href={item.href}
116+
className={cn(
117+
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
118+
isActive
119+
? "border-primary text-primary"
120+
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
121+
)}
122+
>
123+
<Icon className="h-4 w-4" />
124+
{item.name}
125+
</Link>
126+
);
127+
})}
128+
</nav>
129+
130+
<div className="flex flex-col gap-4 w-full mt-6">
90131
<Tabs
91132
defaultValue="monthly"
92133
value={isAnnual ? "annual" : "monthly"}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
2+
import type Stripe from "stripe";
3+
import { Badge } from "@/components/ui/badge";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Table,
7+
TableBody,
8+
TableCell,
9+
TableHead,
10+
TableHeader,
11+
TableRow,
12+
} from "@/components/ui/table";
13+
import { api } from "@/utils/api";
14+
15+
const formatDate = (timestamp: number | null) => {
16+
if (!timestamp) return "-";
17+
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
18+
year: "numeric",
19+
month: "short",
20+
day: "numeric",
21+
});
22+
};
23+
24+
const formatAmount = (amount: number, currency: string) => {
25+
return new Intl.NumberFormat("en-US", {
26+
style: "currency",
27+
currency: currency.toUpperCase(),
28+
}).format(amount / 100);
29+
};
30+
31+
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
32+
const statusConfig: Record<
33+
Stripe.Invoice.Status,
34+
{ label: string; variant: "default" | "secondary" | "destructive" }
35+
> = {
36+
paid: { label: "Paid", variant: "default" },
37+
open: { label: "Open", variant: "secondary" },
38+
draft: { label: "Draft", variant: "secondary" },
39+
void: { label: "Void", variant: "destructive" },
40+
uncollectible: { label: "Uncollectible", variant: "destructive" },
41+
};
42+
43+
if (!status) {
44+
return <Badge variant="secondary">Unknown</Badge>;
45+
}
46+
47+
const config = statusConfig[status] || {
48+
label: status,
49+
variant: "secondary" as const,
50+
};
51+
52+
return <Badge variant={config.variant}>{config.label}</Badge>;
53+
};
54+
55+
export const ShowInvoices = () => {
56+
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
57+
58+
return (
59+
<div className="space-y-4">
60+
{isLoading ? (
61+
<div className="flex items-center justify-center min-h-[20vh]">
62+
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
63+
Loading invoices...
64+
<Loader2 className="animate-spin" />
65+
</span>
66+
</div>
67+
) : invoices && invoices.length > 0 ? (
68+
<div className="rounded-md border">
69+
<Table>
70+
<TableHeader>
71+
<TableRow>
72+
<TableHead>Invoice</TableHead>
73+
<TableHead>Date</TableHead>
74+
<TableHead>Due Date</TableHead>
75+
<TableHead>Amount</TableHead>
76+
<TableHead>Status</TableHead>
77+
<TableHead className="text-right">Actions</TableHead>
78+
</TableRow>
79+
</TableHeader>
80+
<TableBody>
81+
{invoices.map((invoice) => (
82+
<TableRow key={invoice.id}>
83+
<TableCell className="font-medium">
84+
{invoice.number || invoice.id.slice(0, 12)}
85+
</TableCell>
86+
<TableCell>{formatDate(invoice.created)}</TableCell>
87+
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
88+
<TableCell>
89+
{formatAmount(invoice.amountDue, invoice.currency)}
90+
</TableCell>
91+
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
92+
<TableCell className="text-right">
93+
<div className="flex justify-end gap-2">
94+
{invoice.hostedInvoiceUrl && (
95+
<Button
96+
variant="outline"
97+
size="sm"
98+
onClick={() =>
99+
window.open(
100+
invoice.hostedInvoiceUrl || "",
101+
"_blank",
102+
)
103+
}
104+
>
105+
<ExternalLink className="h-4 w-4" />
106+
</Button>
107+
)}
108+
{invoice.invoicePdf && (
109+
<Button
110+
variant="outline"
111+
size="sm"
112+
onClick={() =>
113+
window.open(invoice.invoicePdf || "", "_blank")
114+
}
115+
>
116+
<Download className="h-4 w-4" />
117+
</Button>
118+
)}
119+
</div>
120+
</TableCell>
121+
</TableRow>
122+
))}
123+
</TableBody>
124+
</Table>
125+
</div>
126+
) : (
127+
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
128+
<FileText className="size-12 text-muted-foreground" />
129+
<p className="text-base text-muted-foreground">No invoices found</p>
130+
<p className="text-sm text-muted-foreground">
131+
Your invoices will appear here once you have a subscription
132+
</p>
133+
</div>
134+
)}
135+
</div>
136+
);
137+
};

apps/dokploy/components/dashboard/settings/servers/setup-server.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
DialogTitle,
2323
DialogTrigger,
2424
} from "@/components/ui/dialog";
25-
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
2625
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2726
import { cn } from "@/lib/utils";
2827
import { api } from "@/utils/api";
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
8988
</Button>
9089
</DialogTrigger>
9190
) : (
92-
<DropdownMenuItem
91+
<Button
9392
className="w-full cursor-pointer "
94-
onSelect={(e) => {
95-
e.preventDefault();
93+
size="sm"
94+
onClick={() => {
9695
setIsOpen(true);
9796
}}
9897
>
99-
Setup Server
100-
</DropdownMenuItem>
98+
Setup Server <Settings className="size-4" />
99+
</Button>
101100
)}
102101
<DialogContent className="sm:max-w-4xl ">
103102
<DialogHeader>

0 commit comments

Comments
 (0)