2 Commits

Author SHA1 Message Date
soconnor 4214a4b4de add invoice prefixes, currency passing to pdf gen 2026-04-10 01:28:14 -04:00
soconnor af392e1bc9 remove reordering controls, add auto sort 2026-04-09 23:27:45 -04:00
9 changed files with 339 additions and 307 deletions
@@ -39,7 +39,23 @@ export function PDFDownloadButton({
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");
} catch (error) {
console.error("PDF generation error:", error);
+11 -5
View File
@@ -108,10 +108,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
}
};
const formatCurrency = (amount: number) => {
const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency,
}).format(amount);
};
@@ -233,7 +233,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
onClick={handlePDFExport}
disabled={isExportingPDF}
variant="default"
className="transform-none flex-shrink-0"
className="flex-shrink-0 transform-none"
>
{isExportingPDF ? (
<>
@@ -432,7 +432,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
Tax ({((invoice.taxRate ?? 0) * 100).toFixed(1)}%)
</span>
<span className="text-foreground font-medium">
{formatCurrency(invoice.totalAmount * (invoice.taxRate ?? 0), invoice.currency)}
{formatCurrency(
invoice.totalAmount * (invoice.taxRate ?? 0),
invoice.currency,
)}
</span>
</div>
)}
@@ -440,7 +443,10 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
<div className="flex justify-between text-lg font-bold">
<span className="text-foreground">Total</span>
<span className="text-primary">
{formatCurrency(invoice.totalAmount * (1 + (invoice.taxRate ?? 0)), invoice.currency)}
{formatCurrency(
invoice.totalAmount * (1 + (invoice.taxRate ?? 0)),
invoice.currency,
)}
</span>
</div>
</div>
+49 -36
View File
@@ -15,13 +15,22 @@ import {
SelectValue,
} from "~/components/ui/select";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { PageHeader } from "~/components/layout/page-header";
import { InvoiceLineItems } from "./invoice-line-items";
import { InvoiceCalendarView } from "./invoice-calendar-view";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown } from "lucide-react";
import {
Save,
Calendar as CalendarIcon,
Tag,
User,
List,
FileText,
ChevronDown,
} from "lucide-react";
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
import { Textarea } from "~/components/ui/textarea";
import {
@@ -72,6 +81,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// State
const [formData, setFormData] = useState<InvoiceFormData>({
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
businessId: "",
clientId: "",
issueDate: new Date(),
@@ -101,7 +111,9 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
// Queries (Same as before)
const { data: clients, isLoading: loadingClients } =
api.clients.getAll.useQuery();
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" });
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({
type: "notes",
});
const { data: businesses, isLoading: loadingBusinesses } =
api.businesses.getAll.useQuery();
const { data: existingInvoice, isLoading: loadingInvoice } =
@@ -140,6 +152,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
) || [];
setFormData({
invoiceNumber: existingInvoice.invoiceNumber,
invoicePrefix: existingInvoice.invoicePrefix ?? "#",
businessId: existingInvoice.businessId ?? "",
clientId: existingInvoice.clientId,
issueDate: new Date(existingInvoice.issueDate),
@@ -231,32 +244,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}),
}));
};
const moveItemUp = (idx: number) => {
if (idx === 0) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx - 1]) {
const temp = newItems[idx - 1]!;
newItems[idx - 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const moveItemDown = (idx: number) => {
if (idx === formData.items.length - 1) return;
setFormData((prev) => {
const newItems = [...prev.items];
if (newItems[idx] && newItems[idx + 1]) {
const temp = newItems[idx + 1]!;
newItems[idx + 1] = newItems[idx];
newItems[idx] = temp;
}
return { ...prev, items: newItems };
});
};
const reorderItems = (newItems: InvoiceItem[]) =>
setFormData((prev) => ({ ...prev, items: newItems }));
const createInvoice = api.invoices.create.useMutation({
onSuccess: (inv) => {
@@ -333,6 +320,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
try {
const payload = {
invoiceNumber: formData.invoiceNumber,
invoicePrefix: formData.invoicePrefix,
businessId: formData.businessId || "",
clientId: formData.clientId,
issueDate: formData.issueDate,
@@ -453,13 +441,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
? selectedClient.defaultHourlyRate
: null;
const businessRate =
currentBusiness && "defaultHourlyRate" in currentBusiness
currentBusiness &&
"defaultHourlyRate" in currentBusiness
? currentBusiness.defaultHourlyRate
: null;
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number);
updateField(
"defaultHourlyRate",
(clientRate ?? businessRate ?? 0) as number,
);
// Auto-fill currency from client
if (selectedClient && "currency" in selectedClient && selectedClient.currency) {
updateField("currency", selectedClient.currency as string);
if (
selectedClient &&
"currency" in selectedClient &&
selectedClient.currency
) {
updateField(
"currency",
selectedClient.currency as string,
);
}
}}
>
@@ -525,6 +524,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</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="space-y-2">
<Label>Tax Rate</Label>
@@ -599,7 +611,11 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{noteTemplates && noteTemplates.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
>
Use template <ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
@@ -674,9 +690,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
/>
</CardContent>
</Card>
+6 -93
View File
@@ -1,11 +1,6 @@
"use client";
import {
ChevronDown,
ChevronUp,
Plus,
Trash2,
} from "lucide-react";
import { Plus, Trash2 } from "lucide-react";
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "~/components/ui/button";
@@ -33,9 +28,6 @@ interface InvoiceLineItemsProps {
field: string,
value: string | number | Date,
) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onReorderItems: (items: InvoiceItem[]) => void;
className?: string;
}
@@ -49,61 +41,18 @@ interface LineItemRowProps {
field: string,
value: string | number | Date,
) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
isFirst: boolean;
isLast: boolean;
}
const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
(
{
item,
index,
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
},
ref,
) => {
({ item, index, canRemove, onRemove, onUpdate }, ref) => {
return (
<div
ref={ref}
className={cn(
"bg-card border hidden rounded-xl p-4 md:block transition-all shadow-sm group hover:border-primary/20",
"bg-card group hover:border-primary/20 hidden rounded-xl border p-4 shadow-sm transition-all md:block",
)}
>
<div className="flex items-center gap-3">
{/* Arrow Controls */}
<div className="flex flex-col gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-6 w-6 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-6 w-6 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* Main Content */}
<div className="flex-1 space-y-3">
{/* Description */}
@@ -136,7 +85,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
min={0}
step={0.25}
width="auto"
className="h-9 flex-1 min-w-[100px] font-mono"
className="h-9 min-w-[100px] flex-1 font-mono"
suffix="h"
/>
@@ -148,7 +97,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
step={1}
prefix="$"
width="auto"
className="h-9 flex-1 min-w-[100px] font-mono"
className="h-9 min-w-[100px] flex-1 font-mono"
/>
{/* Amount */}
@@ -185,10 +134,6 @@ function MobileLineItem({
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: LineItemRowProps) {
return (
<motion.div
@@ -253,28 +198,6 @@ function MobileLineItem({
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className="h-8 w-8 p-0"
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className="h-8 w-8 p-0"
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
@@ -310,8 +233,6 @@ export function InvoiceLineItems({
onAddItem,
onRemoveItem,
onUpdateItem,
onMoveUp,
onMoveDown,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
@@ -337,10 +258,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
</motion.div>
@@ -351,10 +268,6 @@ export function InvoiceLineItems({
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
</React.Fragment>
))}
@@ -368,7 +281,7 @@ export function InvoiceLineItems({
type="button"
variant="outline"
onClick={onAddItem}
className="w-full border-dashed border-border py-8 text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 transition-all"
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 w-full border-dashed py-8 transition-all"
>
<Plus className="mr-2 h-4 w-4" />
Add Line Item
@@ -9,98 +9,93 @@ import { InvoiceCalendarView } from "../invoice-calendar-view";
import type { InvoiceFormData } from "./types";
interface InvoiceWorkspaceProps {
formData: InvoiceFormData;
viewMode: "list" | "calendar";
setViewMode: (mode: "list" | "calendar") => void;
addItem: (date?: Date) => void;
removeItem: (index: number) => void;
updateItem: (index: number, field: string, value: string | number | Date) => void;
moveItemUp: (index: number) => void;
moveItemDown: (index: number) => void;
reorderItems: (items: InvoiceFormData['items']) => void;
className?: string;
formData: InvoiceFormData;
viewMode: "list" | "calendar";
setViewMode: (mode: "list" | "calendar") => void;
addItem: (date?: Date) => void;
removeItem: (index: number) => void;
updateItem: (
index: number,
field: string,
value: string | number | Date,
) => void;
className?: string;
}
export function InvoiceWorkspace({
formData,
viewMode,
setViewMode,
addItem,
removeItem,
updateItem,
moveItemUp,
moveItemDown,
reorderItems,
className,
formData,
viewMode,
setViewMode,
addItem,
removeItem,
updateItem,
className,
}: InvoiceWorkspaceProps) {
return (
<div className={cn("flex flex-col h-full", className)}>
{/* Workspace Header / View Toggle */}
<div className="flex items-center justify-between p-4 border-b bg-background/50 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">
{viewMode === 'list' ? 'Line Items' : 'Timesheet'}
</h2>
<div className="text-sm text-muted-foreground ml-2">
{formData.items.length} {formData.items.length === 1 ? 'entry' : 'entries'}
</div>
</div>
<div className="flex items-center bg-secondary/50 p-1 rounded-lg">
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="h-8 gap-2 text-xs"
>
<List className="w-3.5 h-3.5" />
List
</Button>
<Button
variant={viewMode === 'calendar' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('calendar')}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="w-3.5 h-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="flex-1 overflow-hidden relative">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === 'list' ? (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-background/40 backdrop-blur-md rounded-xl border border-white/10 p-1">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onMoveUp={moveItemUp}
onMoveDown={moveItemDown}
onReorderItems={reorderItems}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Workspace Header / View Toggle */}
<div className="bg-background/50 sticky top-0 z-10 flex items-center justify-between border-b p-4 backdrop-blur-sm">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold tracking-tight">
{viewMode === "list" ? "Line Items" : "Timesheet"}
</h2>
<div className="text-muted-foreground ml-2 text-sm">
{formData.items.length}{" "}
{formData.items.length === 1 ? "entry" : "entries"}
</div>
</div>
);
<div className="bg-secondary/50 flex items-center rounded-lg p-1">
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
className="h-8 gap-2 text-xs"
>
<List className="h-3.5 w-3.5" />
List
</Button>
<Button
variant={viewMode === "calendar" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("calendar")}
className="h-8 gap-2 text-xs"
>
<CalendarIcon className="h-3.5 w-3.5" />
Calendar
</Button>
</div>
</div>
{/* Workspace Content */}
<div className="relative flex-1 overflow-hidden">
<div className="absolute inset-0 overflow-y-auto p-6 md:p-8">
{viewMode === "list" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="bg-background/40 rounded-xl border border-white/10 p-1 backdrop-blur-md">
<InvoiceLineItems
items={formData.items}
onAddItem={() => addItem()}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
className="p-4"
/>
</div>
</div>
) : (
<div className="h-full">
<InvoiceCalendarView
items={formData.items}
onAddItem={addItem}
onRemoveItem={removeItem}
onUpdateItem={updateItem}
defaultHourlyRate={formData.defaultHourlyRate}
className="h-full"
/>
</div>
)}
</div>
</div>
</div>
);
}
+21 -20
View File
@@ -4,30 +4,31 @@ export type ClientType = RouterOutputs["clients"]["getAll"][number];
export type BusinessType = RouterOutputs["businesses"]["getAll"][number];
export interface InvoiceItem {
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
export interface InvoiceFormData {
invoiceNumber: string;
businessId: string;
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid";
notes: string;
taxRate: number;
currency: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
invoiceNumber: string;
invoicePrefix: string;
businessId: string;
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid";
notes: string;
taxRate: number;
currency: string;
defaultHourlyRate: number | null;
items: InvoiceItem[];
}
export const STATUS_OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" },
{ value: "draft", label: "Draft" },
{ value: "sent", label: "Sent" },
{ value: "paid", label: "Paid" },
] as const;
+68 -38
View File
@@ -5,11 +5,26 @@ import {
View,
Image,
StyleSheet,
Font,
pdf,
} from "@react-pdf/renderer";
import { saveAs } from "file-saver";
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
function downloadBlob(blob: Blob, filename: string): void {
try {
@@ -56,11 +71,13 @@ function downloadBlob(blob: Blob, filename: string): void {
interface InvoiceData {
invoiceNumber: string;
invoicePrefix?: string | null;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
currency?: string | null;
notes?: string | null;
business?: {
name: string;
@@ -100,7 +117,7 @@ const styles = StyleSheet.create({
page: {
flexDirection: "column",
backgroundColor: "#ffffff",
fontFamily: "Helvetica",
fontFamily: "Frutiger",
fontSize: 10,
paddingTop: 40,
paddingBottom: 80,
@@ -127,7 +144,7 @@ const styles = StyleSheet.create({
},
businessName: {
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
fontSize: 18,
color: "#0f0f0f",
marginBottom: 4,
@@ -135,7 +152,7 @@ const styles = StyleSheet.create({
businessInfo: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginBottom: 3,
@@ -143,7 +160,7 @@ const styles = StyleSheet.create({
businessAddress: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginTop: 4,
@@ -156,14 +173,14 @@ const styles = StyleSheet.create({
invoiceTitle: {
fontSize: 28,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
marginBottom: 8,
},
invoiceNumber: {
fontSize: 14,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#374151",
marginBottom: 4,
},
@@ -172,7 +189,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 11,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
textAlign: "center",
},
@@ -200,13 +217,13 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
marginBottom: 12,
},
clientName: {
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
fontSize: 12,
color: "#0f0f0f",
marginBottom: 2,
@@ -214,7 +231,7 @@ const styles = StyleSheet.create({
clientInfo: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginBottom: 2,
@@ -222,7 +239,7 @@ const styles = StyleSheet.create({
clientAddress: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
marginTop: 4,
@@ -236,14 +253,14 @@ const styles = StyleSheet.create({
detailLabel: {
fontSize: 11,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
flex: 1,
},
detailValue: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
flex: 1,
textAlign: "right",
@@ -259,21 +276,21 @@ const styles = StyleSheet.create({
notesTitle: {
fontSize: 11,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
marginBottom: 6,
},
notesContent: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#374151",
lineHeight: 1.4,
},
businessContact: {
fontSize: 9,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
lineHeight: 1.4,
},
@@ -297,7 +314,7 @@ const styles = StyleSheet.create({
abridgedBusinessName: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
},
@@ -309,13 +326,13 @@ const styles = StyleSheet.create({
abridgedInvoiceTitle: {
fontSize: 14,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
},
abridgedInvoiceNumber: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#374151",
},
@@ -335,7 +352,7 @@ const styles = StyleSheet.create({
tableHeaderCell: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#374151",
paddingHorizontal: 4,
},
@@ -380,7 +397,7 @@ const styles = StyleSheet.create({
color: "#0f0f0f",
paddingHorizontal: 4,
paddingVertical: 2,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
},
tableCellDate: {
@@ -396,7 +413,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 2,
textAlign: "left",
flexWrap: "wrap",
fontFamily: "Helvetica",
fontFamily: "Frutiger",
},
tableCellHours: {
@@ -454,7 +471,7 @@ const styles = StyleSheet.create({
totalLabel: {
fontSize: 11,
color: "#6b7280",
fontFamily: "Helvetica",
fontFamily: "Frutiger",
},
totalAmount: {
@@ -472,7 +489,7 @@ const styles = StyleSheet.create({
finalTotalLabel: {
fontSize: 12,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
},
@@ -484,7 +501,7 @@ const styles = StyleSheet.create({
itemCount: {
fontSize: 9,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#9ca3af",
textAlign: "center",
marginTop: 6,
@@ -511,16 +528,16 @@ const styles = StyleSheet.create({
pageNumber: {
fontSize: 10,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
},
});
// Helper functions
const formatCurrency = (amount: number) => {
const formatCurrency = (amount: number, currency = "USD") => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency,
}).format(amount);
};
@@ -574,7 +591,7 @@ function estimateTextHeight(
): number {
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 maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
@@ -807,7 +824,10 @@ const DenseHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
<View style={styles.invoiceSection}>
<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)}>
<Text>{getStatusLabel(invoice.status)}</Text>
</View>
@@ -873,7 +893,10 @@ const AbridgedHeader: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => (
</Text>
<View style={styles.abridgedInvoiceInfo}>
<Text style={styles.abridgedInvoiceTitle}>INVOICE</Text>
<Text style={styles.abridgedInvoiceNumber}>#{invoice.invoiceNumber}</Text>
<Text style={styles.abridgedInvoiceNumber}>
{invoice.invoicePrefix ?? "#"}
{invoice.invoiceNumber}
</Text>
</View>
</View>
);
@@ -922,7 +945,7 @@ const Footer: React.FC = () => (
<Text
style={{
fontSize: 9,
fontFamily: "Helvetica",
fontFamily: "Frutiger",
color: "#6b7280",
marginLeft: 8,
}}
@@ -944,8 +967,10 @@ const TotalsSection: React.FC<{
invoice: InvoiceData;
items: Array<NonNullable<InvoiceData["items"]>[0]>;
}> = ({ invoice, items }) => {
const currency = invoice.currency ?? "USD";
const subtotal = items.reduce((sum, item) => sum + (item?.amount ?? 0), 0);
const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
return (
<View style={styles.totalsContainer}>
@@ -953,7 +978,7 @@ const TotalsSection: React.FC<{
<Text
style={{
fontSize: 11,
fontFamily: "Helvetica-Bold",
fontFamily: "Frutiger-Bold",
color: "#0f0f0f",
textAlign: "center",
marginBottom: 8,
@@ -965,20 +990,24 @@ const TotalsSection: React.FC<{
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalAmount}>{formatCurrency(subtotal)}</Text>
<Text style={styles.totalAmount}>
{formatCurrency(subtotal, currency)}
</Text>
</View>
{invoice.taxRate > 0 && (
<View style={styles.totalRow}>
<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 style={styles.finalTotalRow}>
<Text style={styles.finalTotalLabel}>TOTAL:</Text>
<Text style={styles.finalTotalAmount}>
{formatCurrency(invoice.totalAmount)}
{formatCurrency(total, currency)}
</Text>
</View>
@@ -994,6 +1023,7 @@ const TotalsSection: React.FC<{
const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
const items = invoice.items?.filter(Boolean) ?? [];
const paginatedItems = paginateItems(items, Boolean(invoice.notes));
const currency = invoice.currency ?? "USD";
return (
<Document>
@@ -1040,12 +1070,12 @@ const InvoicePDF: React.FC<{ invoice: InvoiceData }> = ({ invoice }) => {
{item.hours}
</Text>
<Text style={[styles.tableCell, styles.tableCellRate]}>
{formatCurrency(item.rate)}
{formatCurrency(item.rate, currency)}
</Text>
<Text
style={[styles.tableCell, styles.tableCellAmount]}
>
{formatCurrency(item.amount)}
{formatCurrency(item.amount, currency)}
</Text>
</View>
),
+32 -10
View File
@@ -18,6 +18,7 @@ const invoiceItemSchema = z.object({
const createInvoiceSchema = z.object({
invoiceNumber: z.string().min(1, "Invoice number is required"),
invoicePrefix: z.string().optional().default("#"),
businessId: z
.string()
.min(1, "Business is required")
@@ -416,11 +417,17 @@ export const invoicesRouter = createTRPCRouter({
});
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) {
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
@@ -428,18 +435,27 @@ export const invoicesRouter = createTRPCRouter({
.set({ status: input.status, updatedAt: new Date() })
.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) {
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
.input(z.object({
ids: z.array(z.string()).min(1),
status: z.enum(["draft", "sent", "paid"]),
}))
.input(
z.object({
ids: z.array(z.string()).min(1),
status: z.enum(["draft", "sent", "paid"]),
}),
)
.mutation(async ({ ctx, input }) => {
// Only update invoices owned by this user
const owned = await ctx.db.query.invoices.findMany({
@@ -452,7 +468,10 @@ export const invoicesRouter = createTRPCRouter({
.map((inv) => inv.id);
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
@@ -476,7 +495,10 @@ export const invoicesRouter = createTRPCRouter({
.map((inv) => inv.id);
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));
+51 -15
View File
@@ -1,7 +1,6 @@
import { relations, sql } from "drizzle-orm";
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
* database instance for multiple projects.
@@ -22,7 +21,11 @@ export const users = createTable("user", (d) => ({
emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }),
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)
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetTokenExpiry: d.timestamp(),
@@ -47,7 +50,11 @@ export const usersRelations = relations(users, ({ many }) => ({
export const accounts = createTable(
"account",
(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
.varchar({ length: 255 })
.notNull()
@@ -62,11 +69,13 @@ export const accounts = createTable(
idToken: d.text(),
password: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
updatedAt: d
.timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
}),
(t) => [
index("account_userId_idx").on(t.userId),
],
(t) => [index("account_userId_idx").on(t.userId)],
);
export const accountsRelations = relations(accounts, ({ one }) => ({
@@ -76,7 +85,11 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
export const sessions = createTable(
"session",
(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
.varchar({ length: 255 })
.notNull()
@@ -86,7 +99,11 @@ export const sessions = createTable(
ipAddress: d.text(), // Matched DB: text
userAgent: d.text(), // Matched DB: text
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)],
);
@@ -98,12 +115,20 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export const verificationTokens = createTable(
"verification_token",
(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(),
value: d.varchar({ length: 255 }).notNull(),
expiresAt: d.timestamp().notNull(),
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)],
);
@@ -111,14 +136,25 @@ export const verificationTokens = createTable(
export const ssoProviders = createTable(
"sso_provider",
(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(),
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
oidcConfig: d.text(),
samlConfig: d.text(),
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)],
);
@@ -230,6 +266,7 @@ export const invoices = createTable(
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
invoiceNumber: d.varchar({ length: 100 }).notNull(),
invoicePrefix: d.varchar({ length: 20 }).default("#"),
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
clientId: d
.varchar({ length: 255 })
@@ -411,4 +448,3 @@ export const invoiceTemplatesRelations = relations(
}),
}),
);