mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
add invoice prefixes, currency passing to pdf gen
This commit is contained in:
@@ -39,7 +39,23 @@ export function PDFDownloadButton({
|
|||||||
throw new Error("Invoice not found");
|
throw new Error("Invoice not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateInvoicePDF(invoiceData);
|
// Map invoice to PDF format with currency support
|
||||||
|
const pdfData = {
|
||||||
|
invoiceNumber: invoiceData.invoiceNumber,
|
||||||
|
invoicePrefix: invoiceData.invoicePrefix,
|
||||||
|
issueDate: new Date(invoiceData.issueDate),
|
||||||
|
dueDate: new Date(invoiceData.dueDate),
|
||||||
|
status: invoiceData.status,
|
||||||
|
totalAmount: invoiceData.totalAmount,
|
||||||
|
taxRate: invoiceData.taxRate,
|
||||||
|
currency: invoiceData.currency ?? "USD",
|
||||||
|
notes: invoiceData.notes,
|
||||||
|
business: invoiceData.business,
|
||||||
|
client: invoiceData.client,
|
||||||
|
items: invoiceData.items,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateInvoicePDF(pdfData);
|
||||||
toast.success("PDF downloaded successfully");
|
toast.success("PDF downloaded successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
console.error("PDF generation error:", error);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { InvoiceLineItems } from "./invoice-line-items";
|
import { InvoiceLineItems } from "./invoice-line-items";
|
||||||
@@ -80,6 +81,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
// State
|
// State
|
||||||
const [formData, setFormData] = useState<InvoiceFormData>({
|
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||||
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
|
||||||
|
invoicePrefix: "#",
|
||||||
businessId: "",
|
businessId: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
issueDate: new Date(),
|
issueDate: new Date(),
|
||||||
@@ -150,6 +152,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
) || [];
|
) || [];
|
||||||
setFormData({
|
setFormData({
|
||||||
invoiceNumber: existingInvoice.invoiceNumber,
|
invoiceNumber: existingInvoice.invoiceNumber,
|
||||||
|
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
|
||||||
businessId: existingInvoice.businessId ?? "",
|
businessId: existingInvoice.businessId ?? "",
|
||||||
clientId: existingInvoice.clientId,
|
clientId: existingInvoice.clientId,
|
||||||
issueDate: new Date(existingInvoice.issueDate),
|
issueDate: new Date(existingInvoice.issueDate),
|
||||||
@@ -317,6 +320,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
invoiceNumber: formData.invoiceNumber,
|
invoiceNumber: formData.invoiceNumber,
|
||||||
|
invoicePrefix: formData.invoicePrefix,
|
||||||
businessId: formData.businessId || "",
|
businessId: formData.businessId || "",
|
||||||
clientId: formData.clientId,
|
clientId: formData.clientId,
|
||||||
issueDate: formData.issueDate,
|
issueDate: formData.issueDate,
|
||||||
@@ -520,6 +524,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Prefix</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.invoicePrefix}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField("invoicePrefix", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="#"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Tax Rate</Label>
|
<Label>Tax Rate</Label>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface InvoiceItem {
|
|||||||
|
|
||||||
export interface InvoiceFormData {
|
export interface InvoiceFormData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
invoicePrefix: string;
|
||||||
businessId: string;
|
businessId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
|
|||||||
+68
-38
@@ -5,11 +5,26 @@ import {
|
|||||||
View,
|
View,
|
||||||
Image,
|
Image,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Font,
|
||||||
pdf,
|
pdf,
|
||||||
} from "@react-pdf/renderer";
|
} from "@react-pdf/renderer";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: "Frutiger",
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
src: "/fonts/frutiger/Frutiger.ttf",
|
||||||
|
fontWeight: "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/fonts/frutiger/Frutiger_bold.ttf",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Fallback download function for better browser compatibility
|
// Fallback download function for better browser compatibility
|
||||||
function downloadBlob(blob: Blob, filename: string): void {
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -56,11 +71,13 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
|
|
||||||
interface InvoiceData {
|
interface InvoiceData {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
invoicePrefix?: string | null;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
status: string;
|
status: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
taxRate: number;
|
taxRate: number;
|
||||||
|
currency?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
business?: {
|
business?: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -100,7 +117,7 @@ const styles = StyleSheet.create({
|
|||||||
page: {
|
page: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
paddingTop: 40,
|
paddingTop: 40,
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
@@ -127,7 +144,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
businessName: {
|
businessName: {
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
@@ -135,7 +152,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
businessInfo: {
|
businessInfo: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginBottom: 3,
|
marginBottom: 3,
|
||||||
@@ -143,7 +160,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
businessAddress: {
|
businessAddress: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
@@ -156,14 +173,14 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
invoiceTitle: {
|
invoiceTitle: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
|
|
||||||
invoiceNumber: {
|
invoiceNumber: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
@@ -172,7 +189,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -200,13 +217,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
|
|
||||||
clientName: {
|
clientName: {
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
@@ -214,7 +231,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
@@ -222,7 +239,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
clientAddress: {
|
clientAddress: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
@@ -236,14 +253,14 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
detailLabel: {
|
detailLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
detailValue: {
|
detailValue: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: "right",
|
textAlign: "right",
|
||||||
@@ -259,21 +276,21 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
notesTitle: {
|
notesTitle: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
|
|
||||||
notesContent: {
|
notesContent: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
|
|
||||||
businessContact: {
|
businessContact: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
},
|
},
|
||||||
@@ -297,7 +314,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
abridgedBusinessName: {
|
abridgedBusinessName: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -309,13 +326,13 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
abridgedInvoiceTitle: {
|
abridgedInvoiceTitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
abridgedInvoiceNumber: {
|
abridgedInvoiceNumber: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -335,7 +352,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
tableHeaderCell: {
|
tableHeaderCell: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
@@ -380,7 +397,7 @@ const styles = StyleSheet.create({
|
|||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
},
|
},
|
||||||
|
|
||||||
tableCellDate: {
|
tableCellDate: {
|
||||||
@@ -396,7 +413,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 2,
|
paddingHorizontal: 2,
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
},
|
},
|
||||||
|
|
||||||
tableCellHours: {
|
tableCellHours: {
|
||||||
@@ -454,7 +471,7 @@ const styles = StyleSheet.create({
|
|||||||
totalLabel: {
|
totalLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
},
|
},
|
||||||
|
|
||||||
totalAmount: {
|
totalAmount: {
|
||||||
@@ -472,7 +489,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
finalTotalLabel: {
|
finalTotalLabel: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -484,7 +501,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
itemCount: {
|
itemCount: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#9ca3af",
|
color: "#9ca3af",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@@ -511,16 +528,16 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
pageNumber: {
|
pageNumber: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number, currency = "USD") => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -574,7 +591,7 @@ function estimateTextHeight(
|
|||||||
): number {
|
): number {
|
||||||
if (!text) return fontSize * lineHeight;
|
if (!text) return fontSize * lineHeight;
|
||||||
|
|
||||||
// Rough character width estimation for Helvetica at given font size
|
// Rough character width estimation for Frutiger at given font size
|
||||||
const avgCharWidth = fontSize * 0.6;
|
const avgCharWidth = fontSize * 0.6;
|
||||||
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
|
const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
|
||||||
|
|
||||||
@@ -807,7 +824,10 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|||||||
|
|
||||||
<View style={styles.invoiceSection}>
|
<View style={styles.invoiceSection}>
|
||||||
<Text style={styles.invoiceTitle}>INVOICE</Text>
|
<Text style={styles.invoiceTitle}>INVOICE</Text>
|
||||||
<Text style={styles.invoiceNumber}>#{invoice.invoiceNumber}</Text>
|
<Text style={styles.invoiceNumber}>
|
||||||
|
{invoice.invoicePrefix ?? "#"}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
<View style={getStatusStyle(invoice.status)}>
|
<View style={getStatusStyle(invoice.status)}>
|
||||||
<Text>{getStatusLabel(invoice.status)}</Text>
|
<Text>{getStatusLabel(invoice.status)}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -873,7 +893,10 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
|
|||||||
</Text>
|
</Text>
|
||||||
<View style={styles.abridgedInvoiceInfo}>
|
<View style={styles.abridgedInvoiceInfo}>
|
||||||
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
|
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
|
||||||
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
|
<Text style={styles.abridgedInvoiceNumber}>
|
||||||
|
{invoice.invoicePrefix ?? "#"}
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -922,7 +945,7 @@ const Footer: React.FC = () => (
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: "Helvetica",
|
fontFamily: "Frutiger",
|
||||||
color: "#6b7280",
|
color: "#6b7280",
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -944,8 +967,10 @@ const TotalsSection: React.FC<{
|
|||||||
invoice: InvoiceData;
|
invoice: InvoiceData;
|
||||||
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
items: Array<NonNullable<InvoiceData["items"]>[0]>;
|
||||||
}> = ({ invoice, items }) => {
|
}> = ({ invoice, items }) => {
|
||||||
|
const currency = invoice.currency ?? "USD";
|
||||||
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
|
||||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.totalsContainer}>
|
<View style={styles.totalsContainer}>
|
||||||
@@ -953,7 +978,7 @@ const TotalsSection: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Frutiger-Bold",
|
||||||
color: "#0f0f0f",
|
color: "#0f0f0f",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -965,20 +990,24 @@ const TotalsSection: React.FC<{
|
|||||||
|
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Subtotal:</Text>
|
<Text style={styles.totalLabel}>Subtotal:</Text>
|
||||||
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text>
|
<Text style={styles.totalAmount}>
|
||||||
|
{formatCurrency(subtotal, currency)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<View style={styles.totalRow}>
|
<View style={styles.totalRow}>
|
||||||
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
<Text style={styles.totalLabel}>Tax ({invoice.taxRate}%):</Text>
|
||||||
<Text style={styles.totalAmount}>{formatCurrency(taxAmount)}</Text>
|
<Text style={styles.totalAmount}>
|
||||||
|
{formatCurrency(taxAmount, currency)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.finalTotalRow}>
|
<View style={styles.finalTotalRow}>
|
||||||
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
|
||||||
<Text style={styles.finalTotalAmount}>
|
<Text style={styles.finalTotalAmount}>
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(total, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -994,6 +1023,7 @@ const TotalsSection: React.FC<{
|
|||||||
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
||||||
const items = invoice.items?.filter(Boolean) ?? [];
|
const items = invoice.items?.filter(Boolean) ?? [];
|
||||||
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
|
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
|
||||||
|
const currency = invoice.currency ?? "USD";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
@@ -1040,12 +1070,12 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
|
|||||||
{item.hours}
|
{item.hours}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
<Text style={[styles.tableCell, styles.tableCellRate]}>
|
||||||
{formatCurrency(item.rate)}
|
{formatCurrency(item.rate, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.tableCell, styles.tableCellAmount]}
|
style={[styles.tableCell, styles.tableCellAmount]}
|
||||||
>
|
>
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.amount, currency)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const invoiceItemSchema = z.object({
|
|||||||
|
|
||||||
const createInvoiceSchema = z.object({
|
const createInvoiceSchema = z.object({
|
||||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||||
|
invoicePrefix: z.string().optional().default("#"),
|
||||||
businessId: z
|
businessId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Business is required")
|
.min(1, "Business is required")
|
||||||
@@ -416,11 +417,17 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Invoice not found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.createdById !== ctx.session.user.id) {
|
if (invoice.createdById !== ctx.session.user.id) {
|
||||||
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" });
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You don't have permission to update this invoice",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
@@ -428,18 +435,27 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.set({ status: input.status, updatedAt: new Date() })
|
.set({ status: input.status, updatedAt: new Date() })
|
||||||
.where(eq(invoices.id, input.id));
|
.where(eq(invoices.id, input.id));
|
||||||
|
|
||||||
return { success: true, message: `Invoice status updated to ${input.status}` };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Invoice status updated to ${input.status}`,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) throw error;
|
if (error instanceof TRPCError) throw error;
|
||||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error });
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to update invoice status",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
bulkUpdateStatus: protectedProcedure
|
bulkUpdateStatus: protectedProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
|
z.object({
|
||||||
ids: z.array(z.string()).min(1),
|
ids: z.array(z.string()).min(1),
|
||||||
status: z.enum(["draft", "sent", "paid"]),
|
status: z.enum(["draft", "sent", "paid"]),
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
// Only update invoices owned by this user
|
// Only update invoices owned by this user
|
||||||
const owned = await ctx.db.query.invoices.findMany({
|
const owned = await ctx.db.query.invoices.findMany({
|
||||||
@@ -452,7 +468,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.map((inv) => inv.id);
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
if (ownedIds.length === 0) {
|
if (ownedIds.length === 0) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No matching invoices found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
@@ -476,7 +495,10 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
.map((inv) => inv.id);
|
.map((inv) => inv.id);
|
||||||
|
|
||||||
if (ownedIds.length === 0) {
|
if (ownedIds.length === 0) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No matching invoices found",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
||||||
|
|||||||
+51
-15
@@ -1,7 +1,6 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { index, pgTableCreator } from "drizzle-orm/pg-core";
|
import { index, pgTableCreator } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
* database instance for multiple projects.
|
* database instance for multiple projects.
|
||||||
@@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({
|
|||||||
emailVerified: d.boolean().default(false).notNull(),
|
emailVerified: d.boolean().default(false).notNull(),
|
||||||
image: d.varchar({ length: 255 }),
|
image: d.varchar({ length: 255 }),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
|
||||||
resetTokenExpiry: d.timestamp(),
|
resetTokenExpiry: d.timestamp(),
|
||||||
@@ -47,7 +50,11 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
export const accounts = createTable(
|
export const accounts = createTable(
|
||||||
"account",
|
"account",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
id: d
|
||||||
|
.text()
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
userId: d
|
userId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -62,11 +69,13 @@ export const accounts = createTable(
|
|||||||
idToken: d.text(),
|
idToken: d.text(),
|
||||||
password: d.text(), // Matched DB: text
|
password: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [
|
(t) => [index("account_userId_idx").on(t.userId)],
|
||||||
index("account_userId_idx").on(t.userId),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||||
@@ -76,7 +85,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
|
|||||||
export const sessions = createTable(
|
export const sessions = createTable(
|
||||||
"session",
|
"session",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
id: d
|
||||||
|
.text()
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
userId: d
|
userId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -86,7 +99,11 @@ export const sessions = createTable(
|
|||||||
ipAddress: d.text(), // Matched DB: text
|
ipAddress: d.text(), // Matched DB: text
|
||||||
userAgent: d.text(), // Matched DB: text
|
userAgent: d.text(), // Matched DB: text
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [index("session_userId_idx").on(t.userId)],
|
(t) => [index("session_userId_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -98,12 +115,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
|
|||||||
export const verificationTokens = createTable(
|
export const verificationTokens = createTable(
|
||||||
"verification_token",
|
"verification_token",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
id: d
|
||||||
|
.text()
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()), // Matched DB: text
|
||||||
identifier: d.varchar({ length: 255 }).notNull(),
|
identifier: d.varchar({ length: 255 }).notNull(),
|
||||||
value: d.varchar({ length: 255 }).notNull(),
|
value: d.varchar({ length: 255 }).notNull(),
|
||||||
expiresAt: d.timestamp().notNull(),
|
expiresAt: d.timestamp().notNull(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
|
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
|
||||||
);
|
);
|
||||||
@@ -111,14 +136,25 @@ export const verificationTokens = createTable(
|
|||||||
export const ssoProviders = createTable(
|
export const ssoProviders = createTable(
|
||||||
"sso_provider",
|
"sso_provider",
|
||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.varchar({ length: 255 }).notNull().primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
providerId: d.varchar({ length: 255 }).notNull().unique(),
|
providerId: d.varchar({ length: 255 }).notNull().unique(),
|
||||||
userId: d.varchar({ length: 255 }).notNull().references(() => users.id),
|
userId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
redirectURI: d.varchar({ length: 255 }).notNull().default(""), // Added detailed fields
|
||||||
oidcConfig: d.text(),
|
oidcConfig: d.text(),
|
||||||
samlConfig: d.text(),
|
samlConfig: d.text(),
|
||||||
createdAt: d.timestamp().notNull().defaultNow(),
|
createdAt: d.timestamp().notNull().defaultNow(),
|
||||||
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
|
updatedAt: d
|
||||||
|
.timestamp()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
}),
|
}),
|
||||||
(t) => [index("sso_provider_user_id_idx").on(t.userId)],
|
(t) => [index("sso_provider_user_id_idx").on(t.userId)],
|
||||||
);
|
);
|
||||||
@@ -230,6 +266,7 @@ export const invoices = createTable(
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
invoiceNumber: d.varchar({ length: 100 }).notNull(),
|
||||||
|
invoicePrefix: d.varchar({ length: 20 }).default("#"),
|
||||||
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||||
clientId: d
|
clientId: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -411,4 +448,3 @@ export const invoiceTemplatesRelations = relations(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user