From 9d98cbc2a651133575f2d93e28dc715238b872ad Mon Sep 17 00:00:00 2001 From: mzcoder-hub Date: Tue, 16 Sep 2025 14:24:56 +0700 Subject: [PATCH 1/9] feat: implement IDR currency formatting for invoices - Enhanced currency formatting in invoice preview and email templates - Added proper IDR formatting (no decimals, thousands separators) using Intl.NumberFormat - Updated formatCurrency and formatDecimal utility functions in server utils - Improved invoice preview dialog with organization logo display and responsive design - Added comprehensive currency formatting utilities in /lib/currency-utils.ts - Updated backend invoice email generation to use proper IDR formatting - Enhanced organization logo support across invoice preview and email templates - Added image upload functionality for organization logos - Created responsive invoice preview dialog with print/download capabilities - Updated invoice router to include organization data in API responses The changes ensure IDR amounts display as '1.000.000' instead of '1000000.00' while maintaining proper formatting for other currencies like USD, EUR, etc. --- public/uploads/organization-logo/.gitkeep | 1 + public/uploads/user-avatar/.gitkeep | 1 + src/app/api/upload/route.ts | 152 +++++ .../finance/invoicing/invoice-client.tsx | 27 +- .../invoicing/invoice-preview-dialog.tsx | 521 ++++++++++++++++++ .../organizations/add-organization-dialog.tsx | 23 + .../edit-organization-dialog.tsx | 26 + .../organizations/organization-card.tsx | 8 +- .../settings/organization-info-form.tsx | 42 ++ src/components/ui/image-upload.tsx | 239 ++++++++ src/hooks/use-file-upload.ts | 122 ++++ src/lib/currency-utils.ts | 80 +++ src/server/api/routers/finance/invoice.ts | 45 +- src/server/utils/format.ts | 34 ++ src/types/currencies.ts | 1 + 15 files changed, 1302 insertions(+), 20 deletions(-) create mode 100644 public/uploads/organization-logo/.gitkeep create mode 100644 public/uploads/user-avatar/.gitkeep create mode 100644 src/app/api/upload/route.ts create mode 100644 src/components/finance/invoicing/invoice-preview-dialog.tsx create mode 100644 src/components/ui/image-upload.tsx create mode 100644 src/hooks/use-file-upload.ts create mode 100644 src/lib/currency-utils.ts diff --git a/public/uploads/organization-logo/.gitkeep b/public/uploads/organization-logo/.gitkeep new file mode 100644 index 0000000..b3ca6a7 --- /dev/null +++ b/public/uploads/organization-logo/.gitkeep @@ -0,0 +1 @@ +# Keep this directory in git diff --git a/public/uploads/user-avatar/.gitkeep b/public/uploads/user-avatar/.gitkeep new file mode 100644 index 0000000..b3ca6a7 --- /dev/null +++ b/public/uploads/user-avatar/.gitkeep @@ -0,0 +1 @@ +# Keep this directory in git diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..b22e28f --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,152 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { auth } from "~/lib/auth"; +import { z } from "zod"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/svg+xml", +]; + +const uploadSchema = z.object({ + type: z + .enum(["organization-logo", "user-avatar"]) + .default("organization-logo"), + organizationId: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + // Check authentication + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File; + const type = (formData.get("type") as string) ?? "organization-logo"; + const organizationId = formData.get("organizationId") as string; + + // Validate input + const validatedData = uploadSchema.parse({ type, organizationId }); + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json( + { + error: + "Invalid file type. Only JPEG, PNG, WebP, and SVG files are allowed.", + }, + { status: 400 } + ); + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File too large. Maximum size is 5MB." }, + { status: 400 } + ); + } + + // Create filename with timestamp and random string + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 15); + const fileExtension = file.name.split(".").pop(); + const fileName = `${validatedData.type}-${timestamp}-${randomString}.${fileExtension}`; + + // Create upload directory path + const uploadDir = join( + process.cwd(), + "public", + "uploads", + validatedData.type + ); + await mkdir(uploadDir, { recursive: true }); + + // Convert file to buffer and save + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + const filePath = join(uploadDir, fileName); + + await writeFile(filePath, buffer); + + // Return the public URL + const fileUrl = `/uploads/${validatedData.type}/${fileName}`; + + return NextResponse.json({ + success: true, + url: fileUrl, + filename: fileName, + size: file.size, + type: file.type, + }); + } catch (error) { + console.error("File upload error:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input parameters" }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// Handle file deletion +export async function DELETE(request: NextRequest) { + try { + // Check authentication + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const filePath = searchParams.get("path"); + + if (!filePath?.startsWith("/uploads/")) { + return NextResponse.json({ error: "Invalid file path" }, { status: 400 }); + } + + // Construct full file path + const fullPath = join(process.cwd(), "public", filePath); + + try { + const { unlink } = await import("fs/promises"); + await unlink(fullPath); + + return NextResponse.json({ + success: true, + message: "File deleted successfully", + }); + } catch (fileError) { + // File might not exist, which is okay + return NextResponse.json({ + success: true, + message: "File not found or already deleted", + }); + } + } catch (error) { + console.error("File deletion error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/components/finance/invoicing/invoice-client.tsx b/src/components/finance/invoicing/invoice-client.tsx index cfc240c..88af1e5 100644 --- a/src/components/finance/invoicing/invoice-client.tsx +++ b/src/components/finance/invoicing/invoice-client.tsx @@ -22,10 +22,11 @@ import { DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; import { Badge } from "~/components/ui/badge"; -import { MoreHorizontal, Edit, Trash2, Send, Shield } from "lucide-react"; +import { MoreHorizontal, Edit, Trash2, Send, Shield, Eye } from "lucide-react"; import { InvoiceCreateDialog } from "./invoice-create-dialog"; import { InvoiceEditDialog } from "./invoice-edit-dialog"; import { InvoiceDeleteDialog } from "./invoice-delete-dialog"; +import { InvoicePreviewDialog } from "./invoice-preview-dialog"; import { InvoiceTableSkeleton } from "./invoice-skeletons"; import { invoiceStatusColors, invoiceStatusLabels } from "~/types"; import type { InvoiceClientProps } from "~/types"; @@ -67,6 +68,9 @@ export function InvoiceClient({ organizationId }: InvoiceClientProps) { const [openCreate, setOpenCreate] = useState(false); const [editingInvoice, setEditingInvoice] = useState(null); const [deletingInvoice, setDeletingInvoice] = useState(null); + const [previewingInvoice, setPreviewingInvoice] = useState( + null + ); const utils = api.useUtils(); const { data: invoices = [], isLoading } = api.invoice.listInvoices.useQuery({ organizationId, @@ -177,6 +181,14 @@ export function InvoiceClient({ organizationId }: InvoiceClientProps) {
+
); } diff --git a/src/components/finance/invoicing/invoice-preview-dialog.tsx b/src/components/finance/invoicing/invoice-preview-dialog.tsx new file mode 100644 index 0000000..731aff8 --- /dev/null +++ b/src/components/finance/invoicing/invoice-preview-dialog.tsx @@ -0,0 +1,521 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { api } from "~/trpc/react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { Separator } from "~/components/ui/separator"; +import { Skeleton } from "~/components/ui/skeleton"; +import { + Printer, + Download, + Send, + User, + Calendar, + DollarSign, + FileText, + Building, +} from "lucide-react"; +import { invoiceStatusColors, invoiceStatusLabels } from "~/types"; + +interface InvoicePreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + invoiceId: string | null; + organizationId: string; + onSendEmail?: (invoiceId: string) => void; +} + +interface InvoiceItem { + id?: string; + description: string; + quantity: unknown; + unitPrice: unknown; + taxRate: unknown; + discountRate: unknown; +} + +export function InvoicePreviewDialog({ + open, + onOpenChange, + invoiceId, + onSendEmail, +}: InvoicePreviewDialogProps) { + const [isPrinting, setIsPrinting] = useState(false); + + const { + data: invoice, + isLoading, + error, + } = api.invoice.getInvoiceById.useQuery( + { id: invoiceId! }, + { enabled: !!invoiceId && open } + ); + + const handlePrint = () => { + setIsPrinting(true); + window.print(); + setTimeout(() => setIsPrinting(false), 1000); + }; + + const handleDownload = () => { + if (!invoice) return; + + // Create a simplified HTML version for download + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(generateInvoiceHTML()); + printWindow.document.close(); + printWindow.print(); + } + }; + + const formatAmount = (value: unknown, currency?: string): string => { + let numericValue: number; + + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string") { + numericValue = parseFloat(value); + if (isNaN(numericValue)) return value; + } else if (value && typeof value === "object" && "toString" in value) { + try { + numericValue = parseFloat((value as { toString(): string }).toString()); + if (isNaN(numericValue)) return (value as { toString(): string }).toString(); + } catch { + return "0"; + } + } else { + return "0"; + } + + // Format based on currency + const currencyCode = currency ?? "USD"; + + // IDR doesn't use decimal places and uses thousands separators + if (currencyCode === "IDR") { + return new Intl.NumberFormat("id-ID").format(Math.round(numericValue)); + } + + // For other currencies, use 2 decimal places with appropriate locale + const locale = currencyCode === "USD" ? "en-US" : "en-US"; // Can be extended for other currencies + return new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + }; + + const formatDate = (value: unknown): string => { + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === "string") { + const d = new Date(value); + return isNaN(d.getTime()) ? value : d.toLocaleDateString(); + } + return ""; + }; + + const calculateItemTotal = (item: InvoiceItem) => { + const quantity = + typeof item.quantity === "number" + ? item.quantity + : parseFloat( + (item.quantity as { toString(): string })?.toString() ?? "0" + ); + const unitPrice = + typeof item.unitPrice === "number" + ? item.unitPrice + : parseFloat( + (item.unitPrice as { toString(): string })?.toString() ?? "0" + ); + const subtotal = quantity * unitPrice; + const discount = + subtotal * + (parseFloat( + (item.discountRate as { toString(): string })?.toString() ?? "0" + ) / + 100); + const afterDiscount = subtotal - discount; + const tax = + afterDiscount * + (parseFloat((item.taxRate as { toString(): string })?.toString() ?? "0") / + 100); + return afterDiscount + tax; + }; + + const generateInvoiceHTML = () => { + if (!invoice) return ""; + + return ` + + + + Invoice ${invoice.invoiceNumber} + + + +
+
+ ${ + invoice.organization?.logo + ? `` + : `
🏢
` + } +
+

${invoice.organization?.name ?? "Organization"}

+ ${invoice.organization?.website ? `

${invoice.organization.website}

` : ""} +
+
+
+

INVOICE

+

${invoice.invoiceNumber}

+
+
+ +
+
+ Issue Date: ${formatDate(invoice.issueDate)}
+ Due Date: ${formatDate(invoice.dueDate)}
+ Payment Terms: ${invoice.paymentTerms} +
+
+ Status: ${invoiceStatusLabels[invoice.status]}
+ Currency: ${invoice.currency} +
+
+ +
+ Bill To:
+ ${invoice.customerName ?? `${invoice.customer?.firstName ?? ""} ${invoice.customer?.lastName ?? ""}`.trim()}
+ ${invoice.customerEmail}
+ ${invoice.customer?.address ?? ""} +
+ + + + + + + + + + + + + + ${invoice.items + .map( + item => ` + + + + + + + + + ` + ) + .join("")} + + + + + +
DescriptionQtyUnit PriceDiscountTax RateTotal
${item.description}${formatAmount(item.quantity)}${formatAmount(item.unitPrice, invoice.currency)} ${invoice.currency}${formatAmount(item.discountRate)}%${formatAmount(item.taxRate)}%${formatAmount(calculateItemTotal(item), invoice.currency)} ${invoice.currency}
Total Amount${formatAmount(invoice.totalAmount, invoice.currency)} ${invoice.currency}
+ + ${invoice.notes ? `
Notes:
${invoice.notes}
` : ""} + ${invoice.termsAndConditions ? `
Terms & Conditions:
${invoice.termsAndConditions}
` : ""} + + + `; + }; + + if (!open || !invoiceId) return null; + + return ( + + + + + + Invoice Preview + + + + {isLoading ? ( +
+ + + +
+ ) : error ? ( +
+ Failed to load invoice. Please try again. +
+ ) : !invoice ? ( +
+ Invoice not found. +
+ ) : ( +
+ {/* Action Buttons */} +
+ + + {onSendEmail && invoice.customerEmail && ( + + )} +
+ + {/* Invoice Header */} +
+ {/* Organization Logo */} +
+ {invoice.organization?.logo ? ( + {invoice.organization.name + ) : ( +
+ +
+ )} +
+

+ {invoice.organization?.name ?? "Organization"} +

+ {invoice.organization?.website && ( +

+ {invoice.organization.website} +

+ )} +
+
+ + {/* Invoice Title */} +
+

+ INVOICE +

+

+ {invoice.invoiceNumber} +

+
+
+ + {/* Invoice Info Grid */} +
+
+
+ + Issue Date: + {formatDate(invoice.issueDate)} +
+
+ + Due Date: + {formatDate(invoice.dueDate)} +
+
+ + Payment Terms: + {invoice.paymentTerms} +
+
+ +
+
+ Status: + + {invoiceStatusLabels[invoice.status]} + +
+
+ + Currency: + {invoice.currency} +
+
+ + Paid Amount: + + {formatAmount(invoice.paidAmount, invoice.currency)} {invoice.currency} + +
+
+
+ + + + {/* Customer Information */} +
+
+ +

Bill To

+
+
+

+ {invoice.customerName ?? + `${invoice.customer?.firstName ?? ""} ${invoice.customer?.lastName ?? ""}`.trim()} +

+

{invoice.customerEmail}

+ {invoice.customer?.address && ( +

+ {invoice.customer.address} +

+ )} + {invoice.customer?.phone && ( +

+ {invoice.customer.phone} +

+ )} +
+
+ + + + {/* Invoice Items */} +
+

Items

+
+ + + + + + + + + + + + + {invoice.items.map((item, index) => ( + + + + + + + + + ))} + + + + + + + +
+ Description + + Qty + + Unit Price + + Discount + + Tax Rate + + Total +
+ {item.description} + + {formatAmount(item.quantity)} + + {formatAmount(item.unitPrice, invoice.currency)} {invoice.currency} + + {formatAmount(item.discountRate)}% + + {formatAmount(item.taxRate)}% + + {formatAmount(calculateItemTotal(item), invoice.currency)}{" "} + {invoice.currency} +
+ Total Amount: + + {formatAmount(invoice.totalAmount, invoice.currency)} {invoice.currency} +
+
+
+ + {/* Notes and Terms */} + {(invoice.notes ?? invoice.termsAndConditions) && ( + <> + +
+ {invoice.notes && ( +
+

Notes

+

+ {invoice.notes} +

+
+ )} + {invoice.termsAndConditions && ( +
+

Terms & Conditions

+

+ {invoice.termsAndConditions} +

+
+ )} +
+ + )} +
+ )} +
+
+ ); +} diff --git a/src/components/organizations/add-organization-dialog.tsx b/src/components/organizations/add-organization-dialog.tsx index f3977ad..4e1f6cf 100644 --- a/src/components/organizations/add-organization-dialog.tsx +++ b/src/components/organizations/add-organization-dialog.tsx @@ -24,12 +24,14 @@ import { import { Input } from "~/components/ui/input"; import { Button } from "~/components/ui/button"; import { Textarea } from "~/components/ui/textarea"; +import { ImageUpload } from "~/components/ui/image-upload"; const organizationFormSchema = z.object({ name: z.string().min(1, "Organization name is required"), description: z.string().optional(), website: z.string().url().optional().or(z.literal("")), industry: z.string().optional(), + logo: z.string().optional(), }); export type OrganizationFormValues = z.infer; @@ -62,6 +64,7 @@ export function AddOrganizationDialog({ description: "", website: "", industry: "", + logo: "", }, }); @@ -109,6 +112,26 @@ export function AddOrganizationDialog({ )} /> + ( + + + + + + + )} + /> + ; @@ -39,6 +41,7 @@ interface OrganizationData { description?: string | null; website?: string | null; industry?: string | null; + logo?: string | null; } interface EditOrganizationDialogProps { @@ -63,6 +66,7 @@ export function EditOrganizationDialog({ description: "", website: "", industry: "", + logo: "", }, }); @@ -73,6 +77,7 @@ export function EditOrganizationDialog({ description: organization.description ?? "", website: organization.website ?? "", industry: organization.industry ?? "", + logo: organization.logo ?? "", }); } }, [organization, form]); @@ -121,6 +126,27 @@ export function EditOrganizationDialog({ )} /> + ( + + + + + + + )} + /> +
-
+
{logo ? ( {name} ) : ( diff --git a/src/components/settings/organization-info-form.tsx b/src/components/settings/organization-info-form.tsx index 42f2f11..f6236d9 100644 --- a/src/components/settings/organization-info-form.tsx +++ b/src/components/settings/organization-info-form.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import Image from "next/image"; import { api } from "~/trpc/react"; import { authClient } from "~/lib/auth-client"; import { @@ -22,6 +23,7 @@ import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { Save, Lock, AlertCircle } from "lucide-react"; import { toast } from "sonner"; +import { ImageUpload } from "~/components/ui/image-upload"; const organizationFormSchema = z.object({ name: z @@ -41,6 +43,7 @@ const organizationFormSchema = z.object({ .string() .max(100, "Industry must be less than 100 characters") .optional(), + logo: z.string().optional(), }); type OrganizationFormValues = z.infer; @@ -52,6 +55,7 @@ interface OrganizationInfoFormProps { description?: string | null; website?: string | null; industry?: string | null; + logo?: string | null; }; canEdit: boolean; } @@ -70,6 +74,7 @@ export function OrganizationInfoForm({ description: organization.description ?? "", website: organization.website ?? "", industry: organization.industry ?? "", + logo: organization.logo ?? "", }, }); @@ -119,6 +124,21 @@ export function OrganizationInfoForm({
+ {organization.logo && ( +
+ +
+ {`${organization.name} +
+
+ )} + {organization.description && (
@@ -193,6 +213,28 @@ export function OrganizationInfoForm({ )} /> + {/* Logo Upload */} + ( + + + + + + + )} + /> + {/* Description */} void; + disabled?: boolean; + className?: string; + label?: string; + description?: string; + maxSizeMB?: number; + acceptedFormats?: string[]; + type?: "organization-logo" | "user-avatar"; + organizationId?: string; +} + +export function ImageUpload({ + value, + onChange, + disabled = false, + className, + label = "Logo", + description = "Upload a logo for your organization", + maxSizeMB = 5, + acceptedFormats = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"], + type = "organization-logo", + organizationId, +}: ImageUploadProps) { + const [dragOver, setDragOver] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const { uploadFile, deleteFile, isUploading } = useFileUpload(); + + const handleFileUpload = async (file: File) => { + setError(null); + + const result = await uploadFile(file, { + type, + organizationId, + maxSizeMB, + allowedTypes: acceptedFormats, + }); + + if (result?.url) { + // If there was a previous file, delete it + if (value?.startsWith("/uploads/")) { + await deleteFile(value); + } + onChange(result.url); + } + }; + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + void handleFileUpload(file); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(false); + + const file = event.dataTransfer.files[0]; + if (file) { + void handleFileUpload(file); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(false); + }; + + const handleRemove = async () => { + // Delete file from server if it's a local upload + if (value?.startsWith("/uploads/")) { + await deleteFile(value); + } + onChange(null); + setError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleBrowse = () => { + if (!disabled) { + fileInputRef.current?.click(); + } + }; + + return ( +
+ {label && ( +
+ + {description && ( +

{description}

+ )} +
+ )} + +
+ {/* Current Image Preview */} + {value && ( +
+
+ Uploaded logo +
+ {!disabled && ( + + )} +
+ )} + + {/* Upload Area */} + {!value && ( +
+
+
+
+ {isUploading ? ( +
+ ) : ( + + )} +
+
+ +
+

+ {isUploading + ? "Uploading..." + : "Drop your logo here, or browse"} +

+
+ {acceptedFormats.map(format => ( + + {format.split("/")[1]?.toUpperCase()} + + ))} +
+

+ Max {maxSizeMB}MB +

+
+ + +
+
+ )} + + {/* Replace/Change Button for existing image */} + {value && !disabled && ( + + )} + + {/* Error Message */} + {error && ( +
+ +

{error}

+
+ )} +
+ + {/* Hidden File Input */} + +
+ ); +} diff --git a/src/hooks/use-file-upload.ts b/src/hooks/use-file-upload.ts new file mode 100644 index 0000000..5da475e --- /dev/null +++ b/src/hooks/use-file-upload.ts @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { toast } from "sonner"; + +interface UploadOptions { + type?: "organization-logo" | "user-avatar"; + organizationId?: string; + maxSizeMB?: number; + allowedTypes?: string[]; +} + +interface UploadResponse { + success: boolean; + url?: string; + filename?: string; + size?: number; + type?: string; + error?: string; +} + +export function useFileUpload() { + const [isUploading, setIsUploading] = useState(false); + + const uploadFile = async ( + file: File, + options: UploadOptions = {} + ): Promise => { + const { + type = "organization-logo", + organizationId, + maxSizeMB = 5, + allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/svg+xml", + ], + } = options; + + // Client-side validation + if (!allowedTypes.includes(file.type)) { + toast.error( + `Invalid file type. Please upload ${allowedTypes.map(t => t.split("/")[1]).join(", ")} files.` + ); + return null; + } + + if (file.size > maxSizeMB * 1024 * 1024) { + toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); + return null; + } + + setIsUploading(true); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + if (organizationId) { + formData.append("organizationId", organizationId); + } + + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + const result = (await response.json()) as UploadResponse; + + if (!response.ok) { + throw new Error(result.error ?? "Upload failed"); + } + + if (result.success && result.url) { + toast.success("File uploaded successfully"); + return result; + } else { + throw new Error("Upload failed"); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Upload failed"; + toast.error(errorMessage); + return null; + } finally { + setIsUploading(false); + } + }; + + const deleteFile = async (filePath: string): Promise => { + try { + const response = await fetch( + `/api/upload?path=${encodeURIComponent(filePath)}`, + { + method: "DELETE", + } + ); + + const result = (await response.json()) as { + success: boolean; + error?: string; + }; + + if (!response.ok) { + throw new Error(result.error ?? "Delete failed"); + } + + return result.success; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Delete failed"; + toast.error(errorMessage); + return false; + } + }; + + return { + uploadFile, + deleteFile, + isUploading, + }; +} diff --git a/src/lib/currency-utils.ts b/src/lib/currency-utils.ts new file mode 100644 index 0000000..47c0b6e --- /dev/null +++ b/src/lib/currency-utils.ts @@ -0,0 +1,80 @@ +/** + * Shared currency formatting utilities for both client and server + */ + +/** + * Format currency values based on currency type + */ +export function formatCurrency( + value: number | string | { toString(): string }, + currency = "USD" +): string { + let numericValue: number; + + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string") { + numericValue = parseFloat(value); + if (isNaN(numericValue)) return "0"; + } else if (value && typeof value === "object" && "toString" in value) { + try { + numericValue = parseFloat(value.toString()); + if (isNaN(numericValue)) return "0"; + } catch { + return "0"; + } + } else { + return "0"; + } + + // IDR doesn't use decimal places and uses thousands separators + if (currency === "IDR") { + return new Intl.NumberFormat("id-ID", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(Math.round(numericValue)); + } + + // For other currencies, use 2 decimal places with appropriate locale + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); +} + +/** + * Get currency symbol for display + */ +export function getCurrencySymbol(currency: string): string { + const symbols: Record = { + USD: "$", + EUR: "€", + GBP: "£", + IDR: "Rp", + JPY: "¥", + CNY: "¥", + AUD: "A$", + CAD: "C$", + }; + + return symbols[currency] ?? currency; +} + +/** + * Format currency with symbol + */ +export function formatCurrencyWithSymbol( + value: number | string | { toString(): string }, + currency = "USD" +): string { + const formattedAmount = formatCurrency(value, currency); + const symbol = getCurrencySymbol(currency); + + // For IDR, put symbol before the amount + if (currency === "IDR") { + return `${symbol} ${formattedAmount}`; + } + + // For most other currencies, put symbol after + return `${formattedAmount} ${symbol}`; +} diff --git a/src/server/api/routers/finance/invoice.ts b/src/server/api/routers/finance/invoice.ts index 5b4fdc4..a87879f 100644 --- a/src/server/api/routers/finance/invoice.ts +++ b/src/server/api/routers/finance/invoice.ts @@ -12,7 +12,7 @@ import { Resend } from "resend"; import { env } from "~/env"; import { render } from "@react-email/components"; import { InvoiceEmail } from "~/server/email/templates/invoice-email"; -import { formatDecimalLike } from "~/server/utils/format"; +import { formatCurrency, formatDecimal } from "~/server/utils/format"; // Shared schemas for better reusability and consistency const invoiceItemSchema = z.object({ @@ -316,7 +316,20 @@ export const invoiceRouter = createTRPCRouter({ // Note: invoice access is organization specific; we fetch invoice then verify organization permissions const invoice = await db.invoice.findUnique({ where: { id: input.id }, - include: { items: true, payments: true, customer: true }, + include: { + items: true, + payments: true, + customer: true, + organization: { + select: { + id: true, + name: true, + logo: true, + industry: true, + website: true, + }, + }, + }, }); if (!invoice) throw new Error("Invoice not found"); await ctx.requireAnyPermission(invoice.organizationId); @@ -398,18 +411,18 @@ export const invoiceRouter = createTRPCRouter({ currency: invoice.currency, items: invoice.items.map(i => ({ description: i.description, - quantity: formatDecimalLike(i.quantity), - unitPrice: formatDecimalLike(i.unitPrice), - taxRate: formatDecimalLike(i.taxRate), - discountRate: formatDecimalLike(i.discountRate), - lineTotal: formatDecimalLike(i.subtotal), + quantity: formatDecimal(i.quantity), + unitPrice: formatCurrency(i.unitPrice, invoice.currency), + taxRate: formatDecimal(i.taxRate), + discountRate: formatDecimal(i.discountRate), + lineTotal: formatCurrency(i.subtotal, invoice.currency), })), - subtotal: formatDecimalLike(invoice.subtotal), - taxAmount: formatDecimalLike(invoice.taxAmount), - discountAmount: formatDecimalLike(invoice.discountAmount), - shippingAmount: formatDecimalLike(invoice.shippingAmount), - totalAmount: formatDecimalLike(invoice.totalAmount), - paidAmount: formatDecimalLike(invoice.paidAmount), + subtotal: formatCurrency(invoice.subtotal, invoice.currency), + taxAmount: formatCurrency(invoice.taxAmount, invoice.currency), + discountAmount: formatCurrency(invoice.discountAmount, invoice.currency), + shippingAmount: formatCurrency(invoice.shippingAmount, invoice.currency), + totalAmount: formatCurrency(invoice.totalAmount, invoice.currency), + paidAmount: formatCurrency(invoice.paidAmount, invoice.currency), paymentTerms: invoice.paymentTerms, notes: invoice.notes, termsAndConditions: invoice.termsAndConditions, @@ -417,13 +430,15 @@ export const invoiceRouter = createTRPCRouter({ ); const resend = new Resend(env.RESEND_API_KEY); - await resend.emails.send({ - from: "Acme ", + const sendMail = await resend.emails.send({ + from: env.RESEND_FROM_EMAIL, to: invoice.customerEmail, subject: `Invoice ${invoice.invoiceNumber}`, html: emailHtml, }); + console.log("Resend response", sendMail); + const updated = await db.invoice.update({ where: { id: invoice.id }, data: { diff --git a/src/server/utils/format.ts b/src/server/utils/format.ts index e2a2f0c..bbb887b 100644 --- a/src/server/utils/format.ts +++ b/src/server/utils/format.ts @@ -14,3 +14,37 @@ export function formatDecimalLike( } return value.toFixed(2); } + +/** + * Format currency values based on currency type + */ +export function formatCurrency( + value: Prisma.Decimal | number | string, + currency: string +): string { + const numValue = typeof value === "string" ? parseFloat(value) : Number(value); + + if (currency === "IDR") { + return new Intl.NumberFormat("id-ID", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(numValue); + } + + // Default formatting for other currencies + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numValue); +} + +/** + * Format decimal values for display (quantities, rates, etc.) + */ +export function formatDecimal( + value: Prisma.Decimal | number | string, + decimals = 2 +): string { + const numValue = typeof value === "string" ? parseFloat(value) : Number(value); + return numValue.toFixed(decimals); +} diff --git a/src/types/currencies.ts b/src/types/currencies.ts index 30cc914..6aa9bfb 100644 --- a/src/types/currencies.ts +++ b/src/types/currencies.ts @@ -9,6 +9,7 @@ export const currencies = [ { code: "AUD", name: "Australian Dollar", symbol: "A$" }, { code: "CHF", name: "Swiss Franc", symbol: "CHF" }, { code: "CNY", name: "Chinese Yuan", symbol: "¥" }, + { code: "IDR", name: "Indonesian Rupiah", symbol: "Rp" }, { code: "INR", name: "Indian Rupee", symbol: "₹" }, { code: "BRL", name: "Brazilian Real", symbol: "R$" }, { code: "MXN", name: "Mexican Peso", symbol: "$" }, From ae9beb015b129cc3fdc9834bf3ead5a6fe77e7a4 Mon Sep 17 00:00:00 2001 From: mzcoder-hub Date: Tue, 16 Sep 2025 14:29:01 +0700 Subject: [PATCH 2/9] style: apply code formatting and linting fixes - Remove trailing whitespace - Improve line breaks for better readability - Apply consistent spacing and formatting - No functional changes, only style improvements --- .../invoicing/invoice-preview-dialog.tsx | 23 ++++++++++++------- src/lib/currency-utils.ts | 10 ++++---- src/server/api/routers/finance/invoice.ts | 10 ++++++-- src/server/utils/format.ts | 10 ++++---- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/components/finance/invoicing/invoice-preview-dialog.tsx b/src/components/finance/invoicing/invoice-preview-dialog.tsx index 731aff8..f90563c 100644 --- a/src/components/finance/invoicing/invoice-preview-dialog.tsx +++ b/src/components/finance/invoicing/invoice-preview-dialog.tsx @@ -79,7 +79,7 @@ export function InvoicePreviewDialog({ const formatAmount = (value: unknown, currency?: string): string => { let numericValue: number; - + if (typeof value === "number") { numericValue = value; } else if (typeof value === "string") { @@ -88,7 +88,8 @@ export function InvoicePreviewDialog({ } else if (value && typeof value === "object" && "toString" in value) { try { numericValue = parseFloat((value as { toString(): string }).toString()); - if (isNaN(numericValue)) return (value as { toString(): string }).toString(); + if (isNaN(numericValue)) + return (value as { toString(): string }).toString(); } catch { return "0"; } @@ -98,12 +99,12 @@ export function InvoicePreviewDialog({ // Format based on currency const currencyCode = currency ?? "USD"; - + // IDR doesn't use decimal places and uses thousands separators if (currencyCode === "IDR") { return new Intl.NumberFormat("id-ID").format(Math.round(numericValue)); } - + // For other currencies, use 2 decimal places with appropriate locale const locale = currencyCode === "USD" ? "en-US" : "en-US"; // Can be extended for other currencies return new Intl.NumberFormat(locale, { @@ -385,7 +386,8 @@ export function InvoicePreviewDialog({ Paid Amount: - {formatAmount(invoice.paidAmount, invoice.currency)} {invoice.currency} + {formatAmount(invoice.paidAmount, invoice.currency)}{" "} + {invoice.currency}
@@ -457,7 +459,8 @@ export function InvoicePreviewDialog({ {formatAmount(item.quantity)} - {formatAmount(item.unitPrice, invoice.currency)} {invoice.currency} + {formatAmount(item.unitPrice, invoice.currency)}{" "} + {invoice.currency} {formatAmount(item.discountRate)}% @@ -466,7 +469,10 @@ export function InvoicePreviewDialog({ {formatAmount(item.taxRate)}% - {formatAmount(calculateItemTotal(item), invoice.currency)}{" "} + {formatAmount( + calculateItemTotal(item), + invoice.currency + )}{" "} {invoice.currency} @@ -481,7 +487,8 @@ export function InvoicePreviewDialog({ Total Amount: - {formatAmount(invoice.totalAmount, invoice.currency)} {invoice.currency} + {formatAmount(invoice.totalAmount, invoice.currency)}{" "} + {invoice.currency} diff --git a/src/lib/currency-utils.ts b/src/lib/currency-utils.ts index 47c0b6e..2ad80ff 100644 --- a/src/lib/currency-utils.ts +++ b/src/lib/currency-utils.ts @@ -10,7 +10,7 @@ export function formatCurrency( currency = "USD" ): string { let numericValue: number; - + if (typeof value === "number") { numericValue = value; } else if (typeof value === "string") { @@ -34,7 +34,7 @@ export function formatCurrency( maximumFractionDigits: 0, }).format(Math.round(numericValue)); } - + // For other currencies, use 2 decimal places with appropriate locale return new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, @@ -56,7 +56,7 @@ export function getCurrencySymbol(currency: string): string { AUD: "A$", CAD: "C$", }; - + return symbols[currency] ?? currency; } @@ -69,12 +69,12 @@ export function formatCurrencyWithSymbol( ): string { const formattedAmount = formatCurrency(value, currency); const symbol = getCurrencySymbol(currency); - + // For IDR, put symbol before the amount if (currency === "IDR") { return `${symbol} ${formattedAmount}`; } - + // For most other currencies, put symbol after return `${formattedAmount} ${symbol}`; } diff --git a/src/server/api/routers/finance/invoice.ts b/src/server/api/routers/finance/invoice.ts index a87879f..87f806e 100644 --- a/src/server/api/routers/finance/invoice.ts +++ b/src/server/api/routers/finance/invoice.ts @@ -419,8 +419,14 @@ export const invoiceRouter = createTRPCRouter({ })), subtotal: formatCurrency(invoice.subtotal, invoice.currency), taxAmount: formatCurrency(invoice.taxAmount, invoice.currency), - discountAmount: formatCurrency(invoice.discountAmount, invoice.currency), - shippingAmount: formatCurrency(invoice.shippingAmount, invoice.currency), + discountAmount: formatCurrency( + invoice.discountAmount, + invoice.currency + ), + shippingAmount: formatCurrency( + invoice.shippingAmount, + invoice.currency + ), totalAmount: formatCurrency(invoice.totalAmount, invoice.currency), paidAmount: formatCurrency(invoice.paidAmount, invoice.currency), paymentTerms: invoice.paymentTerms, diff --git a/src/server/utils/format.ts b/src/server/utils/format.ts index bbb887b..d8a97d6 100644 --- a/src/server/utils/format.ts +++ b/src/server/utils/format.ts @@ -22,15 +22,16 @@ export function formatCurrency( value: Prisma.Decimal | number | string, currency: string ): string { - const numValue = typeof value === "string" ? parseFloat(value) : Number(value); - + const numValue = + typeof value === "string" ? parseFloat(value) : Number(value); + if (currency === "IDR") { return new Intl.NumberFormat("id-ID", { minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(numValue); } - + // Default formatting for other currencies return new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, @@ -45,6 +46,7 @@ export function formatDecimal( value: Prisma.Decimal | number | string, decimals = 2 ): string { - const numValue = typeof value === "string" ? parseFloat(value) : Number(value); + const numValue = + typeof value === "string" ? parseFloat(value) : Number(value); return numValue.toFixed(decimals); } From e92ba331b724f7a5468d416f893deba20e461bde Mon Sep 17 00:00:00 2001 From: mzcoder-hub Date: Wed, 17 Sep 2025 09:49:27 +0700 Subject: [PATCH 3/9] fix(api): update baseURL with environment variable for better-auth client --- src/app/api/upload/route.ts | 1 + src/lib/auth-client.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index b22e28f..9ab8371 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -140,6 +140,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ success: true, message: "File not found or already deleted", + fileError: fileError, }); } } catch (error) { diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 7b6d51e..690ae63 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -2,5 +2,5 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ /** the base url of the server (optional if you're using the same domain) */ - baseURL: "http://localhost:3000", + baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", }); From 239821fa9c0a2c368f0e0c6443f75d77973bc2ab Mon Sep 17 00:00:00 2001 From: mzcoder-hub Date: Wed, 17 Sep 2025 09:52:33 +0700 Subject: [PATCH 4/9] feat(api/upload): update file upload logic for better error handling and code refactoring --- src/app/api/upload/route.ts | 306 ++--- .../invoicing/invoice-preview-dialog.tsx | 1056 ++++++++--------- src/components/ui/image-upload.tsx | 478 ++++---- src/hooks/use-file-upload.ts | 244 ++-- src/lib/currency-utils.ts | 160 +-- src/server/api/routers/finance/invoice.ts | 10 +- 6 files changed, 1127 insertions(+), 1127 deletions(-) diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 9ab8371..efe690a 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,153 +1,153 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { writeFile, mkdir } from "fs/promises"; -import { join } from "path"; -import { auth } from "~/lib/auth"; -import { z } from "zod"; - -const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB -const ALLOWED_TYPES = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/webp", - "image/svg+xml", -]; - -const uploadSchema = z.object({ - type: z - .enum(["organization-logo", "user-avatar"]) - .default("organization-logo"), - organizationId: z.string().optional(), -}); - -export async function POST(request: NextRequest) { - try { - // Check authentication - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const formData = await request.formData(); - const file = formData.get("file") as File; - const type = (formData.get("type") as string) ?? "organization-logo"; - const organizationId = formData.get("organizationId") as string; - - // Validate input - const validatedData = uploadSchema.parse({ type, organizationId }); - - if (!file) { - return NextResponse.json({ error: "No file provided" }, { status: 400 }); - } - - // Validate file type - if (!ALLOWED_TYPES.includes(file.type)) { - return NextResponse.json( - { - error: - "Invalid file type. Only JPEG, PNG, WebP, and SVG files are allowed.", - }, - { status: 400 } - ); - } - - // Validate file size - if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( - { error: "File too large. Maximum size is 5MB." }, - { status: 400 } - ); - } - - // Create filename with timestamp and random string - const timestamp = Date.now(); - const randomString = Math.random().toString(36).substring(2, 15); - const fileExtension = file.name.split(".").pop(); - const fileName = `${validatedData.type}-${timestamp}-${randomString}.${fileExtension}`; - - // Create upload directory path - const uploadDir = join( - process.cwd(), - "public", - "uploads", - validatedData.type - ); - await mkdir(uploadDir, { recursive: true }); - - // Convert file to buffer and save - const bytes = await file.arrayBuffer(); - const buffer = Buffer.from(bytes); - const filePath = join(uploadDir, fileName); - - await writeFile(filePath, buffer); - - // Return the public URL - const fileUrl = `/uploads/${validatedData.type}/${fileName}`; - - return NextResponse.json({ - success: true, - url: fileUrl, - filename: fileName, - size: file.size, - type: file.type, - }); - } catch (error) { - console.error("File upload error:", error); - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Invalid input parameters" }, - { status: 400 } - ); - } - - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} - -// Handle file deletion -export async function DELETE(request: NextRequest) { - try { - // Check authentication - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { searchParams } = new URL(request.url); - const filePath = searchParams.get("path"); - - if (!filePath?.startsWith("/uploads/")) { - return NextResponse.json({ error: "Invalid file path" }, { status: 400 }); - } - - // Construct full file path - const fullPath = join(process.cwd(), "public", filePath); - - try { - const { unlink } = await import("fs/promises"); - await unlink(fullPath); - - return NextResponse.json({ - success: true, - message: "File deleted successfully", - }); - } catch (fileError) { - // File might not exist, which is okay - return NextResponse.json({ - success: true, - message: "File not found or already deleted", - fileError: fileError, - }); - } - } catch (error) { - console.error("File deletion error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} +import { type NextRequest, NextResponse } from "next/server"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { auth } from "~/lib/auth"; +import { z } from "zod"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/svg+xml", +]; + +const uploadSchema = z.object({ + type: z + .enum(["organization-logo", "user-avatar"]) + .default("organization-logo"), + organizationId: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + // Check authentication + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file") as File; + const type = (formData.get("type") as string) ?? "organization-logo"; + const organizationId = formData.get("organizationId") as string; + + // Validate input + const validatedData = uploadSchema.parse({ type, organizationId }); + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json( + { + error: + "Invalid file type. Only JPEG, PNG, WebP, and SVG files are allowed.", + }, + { status: 400 } + ); + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File too large. Maximum size is 5MB." }, + { status: 400 } + ); + } + + // Create filename with timestamp and random string + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 15); + const fileExtension = file.name.split(".").pop(); + const fileName = `${validatedData.type}-${timestamp}-${randomString}.${fileExtension}`; + + // Create upload directory path + const uploadDir = join( + process.cwd(), + "public", + "uploads", + validatedData.type + ); + await mkdir(uploadDir, { recursive: true }); + + // Convert file to buffer and save + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + const filePath = join(uploadDir, fileName); + + await writeFile(filePath, buffer); + + // Return the public URL + const fileUrl = `/uploads/${validatedData.type}/${fileName}`; + + return NextResponse.json({ + success: true, + url: fileUrl, + filename: fileName, + size: file.size, + type: file.type, + }); + } catch (error) { + console.error("File upload error:", error); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid input parameters" }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// Handle file deletion +export async function DELETE(request: NextRequest) { + try { + // Check authentication + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const filePath = searchParams.get("path"); + + if (!filePath?.startsWith("/uploads/")) { + return NextResponse.json({ error: "Invalid file path" }, { status: 400 }); + } + + // Construct full file path + const fullPath = join(process.cwd(), "public", filePath); + + try { + const { unlink } = await import("fs/promises"); + await unlink(fullPath); + + return NextResponse.json({ + success: true, + message: "File deleted successfully", + }); + } catch (fileError) { + // File might not exist, which is okay + return NextResponse.json({ + success: true, + message: "File not found or already deleted", + fileError: fileError, + }); + } + } catch (error) { + console.error("File deletion error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/components/finance/invoicing/invoice-preview-dialog.tsx b/src/components/finance/invoicing/invoice-preview-dialog.tsx index f90563c..fe3505a 100644 --- a/src/components/finance/invoicing/invoice-preview-dialog.tsx +++ b/src/components/finance/invoicing/invoice-preview-dialog.tsx @@ -1,528 +1,528 @@ -"use client"; - -import { useState } from "react"; -import Image from "next/image"; -import { api } from "~/trpc/react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "~/components/ui/dialog"; -import { Button } from "~/components/ui/button"; -import { Badge } from "~/components/ui/badge"; -import { Separator } from "~/components/ui/separator"; -import { Skeleton } from "~/components/ui/skeleton"; -import { - Printer, - Download, - Send, - User, - Calendar, - DollarSign, - FileText, - Building, -} from "lucide-react"; -import { invoiceStatusColors, invoiceStatusLabels } from "~/types"; - -interface InvoicePreviewDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - invoiceId: string | null; - organizationId: string; - onSendEmail?: (invoiceId: string) => void; -} - -interface InvoiceItem { - id?: string; - description: string; - quantity: unknown; - unitPrice: unknown; - taxRate: unknown; - discountRate: unknown; -} - -export function InvoicePreviewDialog({ - open, - onOpenChange, - invoiceId, - onSendEmail, -}: InvoicePreviewDialogProps) { - const [isPrinting, setIsPrinting] = useState(false); - - const { - data: invoice, - isLoading, - error, - } = api.invoice.getInvoiceById.useQuery( - { id: invoiceId! }, - { enabled: !!invoiceId && open } - ); - - const handlePrint = () => { - setIsPrinting(true); - window.print(); - setTimeout(() => setIsPrinting(false), 1000); - }; - - const handleDownload = () => { - if (!invoice) return; - - // Create a simplified HTML version for download - const printWindow = window.open("", "_blank"); - if (printWindow) { - printWindow.document.write(generateInvoiceHTML()); - printWindow.document.close(); - printWindow.print(); - } - }; - - const formatAmount = (value: unknown, currency?: string): string => { - let numericValue: number; - - if (typeof value === "number") { - numericValue = value; - } else if (typeof value === "string") { - numericValue = parseFloat(value); - if (isNaN(numericValue)) return value; - } else if (value && typeof value === "object" && "toString" in value) { - try { - numericValue = parseFloat((value as { toString(): string }).toString()); - if (isNaN(numericValue)) - return (value as { toString(): string }).toString(); - } catch { - return "0"; - } - } else { - return "0"; - } - - // Format based on currency - const currencyCode = currency ?? "USD"; - - // IDR doesn't use decimal places and uses thousands separators - if (currencyCode === "IDR") { - return new Intl.NumberFormat("id-ID").format(Math.round(numericValue)); - } - - // For other currencies, use 2 decimal places with appropriate locale - const locale = currencyCode === "USD" ? "en-US" : "en-US"; // Can be extended for other currencies - return new Intl.NumberFormat(locale, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(numericValue); - }; - - const formatDate = (value: unknown): string => { - if (value instanceof Date) return value.toLocaleDateString(); - if (typeof value === "string") { - const d = new Date(value); - return isNaN(d.getTime()) ? value : d.toLocaleDateString(); - } - return ""; - }; - - const calculateItemTotal = (item: InvoiceItem) => { - const quantity = - typeof item.quantity === "number" - ? item.quantity - : parseFloat( - (item.quantity as { toString(): string })?.toString() ?? "0" - ); - const unitPrice = - typeof item.unitPrice === "number" - ? item.unitPrice - : parseFloat( - (item.unitPrice as { toString(): string })?.toString() ?? "0" - ); - const subtotal = quantity * unitPrice; - const discount = - subtotal * - (parseFloat( - (item.discountRate as { toString(): string })?.toString() ?? "0" - ) / - 100); - const afterDiscount = subtotal - discount; - const tax = - afterDiscount * - (parseFloat((item.taxRate as { toString(): string })?.toString() ?? "0") / - 100); - return afterDiscount + tax; - }; - - const generateInvoiceHTML = () => { - if (!invoice) return ""; - - return ` - - - - Invoice ${invoice.invoiceNumber} - - - -
-
- ${ - invoice.organization?.logo - ? `` - : `
🏢
` - } -
-

${invoice.organization?.name ?? "Organization"}

- ${invoice.organization?.website ? `

${invoice.organization.website}

` : ""} -
-
-
-

INVOICE

-

${invoice.invoiceNumber}

-
-
- -
-
- Issue Date: ${formatDate(invoice.issueDate)}
- Due Date: ${formatDate(invoice.dueDate)}
- Payment Terms: ${invoice.paymentTerms} -
-
- Status: ${invoiceStatusLabels[invoice.status]}
- Currency: ${invoice.currency} -
-
- -
- Bill To:
- ${invoice.customerName ?? `${invoice.customer?.firstName ?? ""} ${invoice.customer?.lastName ?? ""}`.trim()}
- ${invoice.customerEmail}
- ${invoice.customer?.address ?? ""} -
- - - - - - - - - - - - - - ${invoice.items - .map( - item => ` - - - - - - - - - ` - ) - .join("")} - - - - - -
DescriptionQtyUnit PriceDiscountTax RateTotal
${item.description}${formatAmount(item.quantity)}${formatAmount(item.unitPrice, invoice.currency)} ${invoice.currency}${formatAmount(item.discountRate)}%${formatAmount(item.taxRate)}%${formatAmount(calculateItemTotal(item), invoice.currency)} ${invoice.currency}
Total Amount${formatAmount(invoice.totalAmount, invoice.currency)} ${invoice.currency}
- - ${invoice.notes ? `
Notes:
${invoice.notes}
` : ""} - ${invoice.termsAndConditions ? `
Terms & Conditions:
${invoice.termsAndConditions}
` : ""} - - - `; - }; - - if (!open || !invoiceId) return null; - - return ( - - - - - - Invoice Preview - - - - {isLoading ? ( -
- - - -
- ) : error ? ( -
- Failed to load invoice. Please try again. -
- ) : !invoice ? ( -
- Invoice not found. -
- ) : ( -
- {/* Action Buttons */} -
- - - {onSendEmail && invoice.customerEmail && ( - - )} -
- - {/* Invoice Header */} -
- {/* Organization Logo */} -
- {invoice.organization?.logo ? ( - {invoice.organization.name - ) : ( -
- -
- )} -
-

- {invoice.organization?.name ?? "Organization"} -

- {invoice.organization?.website && ( -

- {invoice.organization.website} -

- )} -
-
- - {/* Invoice Title */} -
-

- INVOICE -

-

- {invoice.invoiceNumber} -

-
-
- - {/* Invoice Info Grid */} -
-
-
- - Issue Date: - {formatDate(invoice.issueDate)} -
-
- - Due Date: - {formatDate(invoice.dueDate)} -
-
- - Payment Terms: - {invoice.paymentTerms} -
-
- -
-
- Status: - - {invoiceStatusLabels[invoice.status]} - -
-
- - Currency: - {invoice.currency} -
-
- - Paid Amount: - - {formatAmount(invoice.paidAmount, invoice.currency)}{" "} - {invoice.currency} - -
-
-
- - - - {/* Customer Information */} -
-
- -

Bill To

-
-
-

- {invoice.customerName ?? - `${invoice.customer?.firstName ?? ""} ${invoice.customer?.lastName ?? ""}`.trim()} -

-

{invoice.customerEmail}

- {invoice.customer?.address && ( -

- {invoice.customer.address} -

- )} - {invoice.customer?.phone && ( -

- {invoice.customer.phone} -

- )} -
-
- - - - {/* Invoice Items */} -
-

Items

-
- - - - - - - - - - - - - {invoice.items.map((item, index) => ( - - - - - - - - - ))} - - - - - - - -
- Description - - Qty - - Unit Price - - Discount - - Tax Rate - - Total -
- {item.description} - - {formatAmount(item.quantity)} - - {formatAmount(item.unitPrice, invoice.currency)}{" "} - {invoice.currency} - - {formatAmount(item.discountRate)}% - - {formatAmount(item.taxRate)}% - - {formatAmount( - calculateItemTotal(item), - invoice.currency - )}{" "} - {invoice.currency} -
- Total Amount: - - {formatAmount(invoice.totalAmount, invoice.currency)}{" "} - {invoice.currency} -
-
-
- - {/* Notes and Terms */} - {(invoice.notes ?? invoice.termsAndConditions) && ( - <> - -
- {invoice.notes && ( -
-

Notes

-

- {invoice.notes} -

-
- )} - {invoice.termsAndConditions && ( -
-

Terms & Conditions

-

- {invoice.termsAndConditions} -

-
- )} -
- - )} -
- )} -
-
- ); -} +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { api } from "~/trpc/react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { Separator } from "~/components/ui/separator"; +import { Skeleton } from "~/components/ui/skeleton"; +import { + Printer, + Download, + Send, + User, + Calendar, + DollarSign, + FileText, + Building, +} from "lucide-react"; +import { invoiceStatusColors, invoiceStatusLabels } from "~/types"; + +interface InvoicePreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + invoiceId: string | null; + organizationId: string; + onSendEmail?: (invoiceId: string) => void; +} + +interface InvoiceItem { + id?: string; + description: string; + quantity: unknown; + unitPrice: unknown; + taxRate: unknown; + discountRate: unknown; +} + +export function InvoicePreviewDialog({ + open, + onOpenChange, + invoiceId, + onSendEmail, +}: InvoicePreviewDialogProps) { + const [isPrinting, setIsPrinting] = useState(false); + + const { + data: invoice, + isLoading, + error, + } = api.invoice.getInvoiceById.useQuery( + { id: invoiceId! }, + { enabled: !!invoiceId && open } + ); + + const handlePrint = () => { + setIsPrinting(true); + window.print(); + setTimeout(() => setIsPrinting(false), 1000); + }; + + const handleDownload = () => { + if (!invoice) return; + + // Create a simplified HTML version for download + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(generateInvoiceHTML()); + printWindow.document.close(); + printWindow.print(); + } + }; + + const formatAmount = (value: unknown, currency?: string): string => { + let numericValue: number; + + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string") { + numericValue = parseFloat(value); + if (isNaN(numericValue)) return value; + } else if (value && typeof value === "object" && "toString" in value) { + try { + numericValue = parseFloat((value as { toString(): string }).toString()); + if (isNaN(numericValue)) + return (value as { toString(): string }).toString(); + } catch { + return "0"; + } + } else { + return "0"; + } + + // Format based on currency + const currencyCode = currency ?? "USD"; + + // IDR doesn't use decimal places and uses thousands separators + if (currencyCode === "IDR") { + return new Intl.NumberFormat("id-ID").format(Math.round(numericValue)); + } + + // For other currencies, use 2 decimal places with appropriate locale + const locale = currencyCode === "USD" ? "en-US" : "en-US"; // Can be extended for other currencies + return new Intl.NumberFormat(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); + }; + + const formatDate = (value: unknown): string => { + if (value instanceof Date) return value.toLocaleDateString(); + if (typeof value === "string") { + const d = new Date(value); + return isNaN(d.getTime()) ? value : d.toLocaleDateString(); + } + return ""; + }; + + const calculateItemTotal = (item: InvoiceItem) => { + const quantity = + typeof item.quantity === "number" + ? item.quantity + : parseFloat( + (item.quantity as { toString(): string })?.toString() ?? "0" + ); + const unitPrice = + typeof item.unitPrice === "number" + ? item.unitPrice + : parseFloat( + (item.unitPrice as { toString(): string })?.toString() ?? "0" + ); + const subtotal = quantity * unitPrice; + const discount = + subtotal * + (parseFloat( + (item.discountRate as { toString(): string })?.toString() ?? "0" + ) / + 100); + const afterDiscount = subtotal - discount; + const tax = + afterDiscount * + (parseFloat((item.taxRate as { toString(): string })?.toString() ?? "0") / + 100); + return afterDiscount + tax; + }; + + const generateInvoiceHTML = () => { + if (!invoice) return ""; + + return ` + + + + Invoice ${invoice.invoiceNumber} + + + +
+
+ ${ + invoice.organization?.logo + ? `` + : `
🏢
` + } +
+

${invoice.organization?.name ?? "Organization"}

+ ${invoice.organization?.website ? `

${invoice.organization.website}

` : ""} +
+
+
+

INVOICE

+

${invoice.invoiceNumber}

+
+
+ +
+
+ Issue Date: ${formatDate(invoice.issueDate)}
+ Due Date: ${formatDate(invoice.dueDate)}
+ Payment Terms: ${invoice.paymentTerms} +
+
+ Status: ${invoiceStatusLabels[invoice.status]}
+ Currency: ${invoice.currency} +
+
+ +
+ Bill To:
+ ${invoice.customerName ?? `${invoice.customer?.firstName ?? ""} ${invoice.customer?.lastName ?? ""}`.trim()}
+ ${invoice.customerEmail}
+ ${invoice.customer?.address ?? ""} +
+ + + + + + + + + + + + + + ${invoice.items + .map( + item => ` + + + + + + + + + ` + ) + .join("")} + + + + + +
DescriptionQtyUnit PriceDiscountTax RateTotal
${item.description}${formatAmount(item.quantity)}${formatAmount(item.unitPrice, invoice.currency)} ${invoice.currency}${formatAmount(item.discountRate)}%${formatAmount(item.taxRate)}%${formatAmount(calculateItemTotal(item), invoice.currency)} ${invoice.currency}
Total Amount${formatAmount(invoice.totalAmount, invoice.currency)} ${invoice.currency}
+ + ${invoice.notes ? `
Notes:
${invoice.notes}
` : ""} + ${invoice.termsAndConditions ? `
Terms & Conditions:
${invoice.termsAndConditions}
` : ""} + + + `; + }; + + if (!open || !invoiceId) return null; + + return ( + + + + + + Invoice Preview + + + + {isLoading ? ( +
+ + + +
+ ) : error ? ( +
+ Failed to load invoice. Please try again. +
+ ) : !invoice ? ( +
+ Invoice not found. +
+ ) : ( +
+ {/* Action Buttons */} +
+ + + {onSendEmail && invoice.customerEmail && ( + + )} +
+ + {/* Invoice Header */} +
+ {/* Organization Logo */} +
+ {invoice.organization?.logo ? ( + {invoice.organization.name + ) : ( +
+ +
+ )} +
+

+ {invoice.organization?.name ?? "Organization"} +

+ {invoice.organization?.website && ( +

+ {invoice.organization.website} +

+ )} +
+
+ + {/* Invoice Title */} +
+

+ INVOICE +

+

+ {invoice.invoiceNumber} +

+
+
+ + {/* Invoice Info Grid */} +
+
+
+ + Issue Date: + {formatDate(invoice.issueDate)} +
+
+ + Due Date: + {formatDate(invoice.dueDate)} +
+
+ + Payment Terms: + {invoice.paymentTerms} +
+
+ +
+
+ Status: + + {invoiceStatusLabels[invoice.status]} + +
+
+ + Currency: + {invoice.currency} +
+
+ + Paid Amount: + + {formatAmount(invoice.paidAmount, invoice.currency)}{" "} + {invoice.currency} + +
+
+
+ + + + {/* Customer Information */} +
+
+ +

Bill To

+
+
+

+ {invoice.customerName ?? + `${invoice.customer?.firstName ?? ""} ${invoice.customer?.lastName ?? ""}`.trim()} +

+

{invoice.customerEmail}

+ {invoice.customer?.address && ( +

+ {invoice.customer.address} +

+ )} + {invoice.customer?.phone && ( +

+ {invoice.customer.phone} +

+ )} +
+
+ + + + {/* Invoice Items */} +
+

Items

+
+ + + + + + + + + + + + + {invoice.items.map((item, index) => ( + + + + + + + + + ))} + + + + + + + +
+ Description + + Qty + + Unit Price + + Discount + + Tax Rate + + Total +
+ {item.description} + + {formatAmount(item.quantity)} + + {formatAmount(item.unitPrice, invoice.currency)}{" "} + {invoice.currency} + + {formatAmount(item.discountRate)}% + + {formatAmount(item.taxRate)}% + + {formatAmount( + calculateItemTotal(item), + invoice.currency + )}{" "} + {invoice.currency} +
+ Total Amount: + + {formatAmount(invoice.totalAmount, invoice.currency)}{" "} + {invoice.currency} +
+
+
+ + {/* Notes and Terms */} + {(invoice.notes ?? invoice.termsAndConditions) && ( + <> + +
+ {invoice.notes && ( +
+

Notes

+

+ {invoice.notes} +

+
+ )} + {invoice.termsAndConditions && ( +
+

Terms & Conditions

+

+ {invoice.termsAndConditions} +

+
+ )} +
+ + )} +
+ )} +
+
+ ); +} diff --git a/src/components/ui/image-upload.tsx b/src/components/ui/image-upload.tsx index 95a97f7..4f19927 100644 --- a/src/components/ui/image-upload.tsx +++ b/src/components/ui/image-upload.tsx @@ -1,239 +1,239 @@ -"use client"; - -import { useState, useRef } from "react"; -import Image from "next/image"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { Badge } from "~/components/ui/badge"; -import { Upload, X, ImageIcon, AlertCircle } from "lucide-react"; -import { cn } from "~/lib/utils"; -import { useFileUpload } from "~/hooks/use-file-upload"; - -interface ImageUploadProps { - value?: string | null; - onChange: (value: string | null) => void; - disabled?: boolean; - className?: string; - label?: string; - description?: string; - maxSizeMB?: number; - acceptedFormats?: string[]; - type?: "organization-logo" | "user-avatar"; - organizationId?: string; -} - -export function ImageUpload({ - value, - onChange, - disabled = false, - className, - label = "Logo", - description = "Upload a logo for your organization", - maxSizeMB = 5, - acceptedFormats = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"], - type = "organization-logo", - organizationId, -}: ImageUploadProps) { - const [dragOver, setDragOver] = useState(false); - const [error, setError] = useState(null); - const fileInputRef = useRef(null); - - const { uploadFile, deleteFile, isUploading } = useFileUpload(); - - const handleFileUpload = async (file: File) => { - setError(null); - - const result = await uploadFile(file, { - type, - organizationId, - maxSizeMB, - allowedTypes: acceptedFormats, - }); - - if (result?.url) { - // If there was a previous file, delete it - if (value?.startsWith("/uploads/")) { - await deleteFile(value); - } - onChange(result.url); - } - }; - - const handleFileSelect = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - void handleFileUpload(file); - } - }; - - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - setDragOver(false); - - const file = event.dataTransfer.files[0]; - if (file) { - void handleFileUpload(file); - } - }; - - const handleDragOver = (event: React.DragEvent) => { - event.preventDefault(); - setDragOver(true); - }; - - const handleDragLeave = (event: React.DragEvent) => { - event.preventDefault(); - setDragOver(false); - }; - - const handleRemove = async () => { - // Delete file from server if it's a local upload - if (value?.startsWith("/uploads/")) { - await deleteFile(value); - } - onChange(null); - setError(null); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - }; - - const handleBrowse = () => { - if (!disabled) { - fileInputRef.current?.click(); - } - }; - - return ( -
- {label && ( -
- - {description && ( -

{description}

- )} -
- )} - -
- {/* Current Image Preview */} - {value && ( -
-
- Uploaded logo -
- {!disabled && ( - - )} -
- )} - - {/* Upload Area */} - {!value && ( -
-
-
-
- {isUploading ? ( -
- ) : ( - - )} -
-
- -
-

- {isUploading - ? "Uploading..." - : "Drop your logo here, or browse"} -

-
- {acceptedFormats.map(format => ( - - {format.split("/")[1]?.toUpperCase()} - - ))} -
-

- Max {maxSizeMB}MB -

-
- - -
-
- )} - - {/* Replace/Change Button for existing image */} - {value && !disabled && ( - - )} - - {/* Error Message */} - {error && ( -
- -

{error}

-
- )} -
- - {/* Hidden File Input */} - -
- ); -} +"use client"; + +import { useState, useRef } from "react"; +import Image from "next/image"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Badge } from "~/components/ui/badge"; +import { Upload, X, ImageIcon, AlertCircle } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { useFileUpload } from "~/hooks/use-file-upload"; + +interface ImageUploadProps { + value?: string | null; + onChange: (value: string | null) => void; + disabled?: boolean; + className?: string; + label?: string; + description?: string; + maxSizeMB?: number; + acceptedFormats?: string[]; + type?: "organization-logo" | "user-avatar"; + organizationId?: string; +} + +export function ImageUpload({ + value, + onChange, + disabled = false, + className, + label = "Logo", + description = "Upload a logo for your organization", + maxSizeMB = 5, + acceptedFormats = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"], + type = "organization-logo", + organizationId, +}: ImageUploadProps) { + const [dragOver, setDragOver] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const { uploadFile, deleteFile, isUploading } = useFileUpload(); + + const handleFileUpload = async (file: File) => { + setError(null); + + const result = await uploadFile(file, { + type, + organizationId, + maxSizeMB, + allowedTypes: acceptedFormats, + }); + + if (result?.url) { + // If there was a previous file, delete it + if (value?.startsWith("/uploads/")) { + await deleteFile(value); + } + onChange(result.url); + } + }; + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + void handleFileUpload(file); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(false); + + const file = event.dataTransfer.files[0]; + if (file) { + void handleFileUpload(file); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setDragOver(false); + }; + + const handleRemove = async () => { + // Delete file from server if it's a local upload + if (value?.startsWith("/uploads/")) { + await deleteFile(value); + } + onChange(null); + setError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleBrowse = () => { + if (!disabled) { + fileInputRef.current?.click(); + } + }; + + return ( +
+ {label && ( +
+ + {description && ( +

{description}

+ )} +
+ )} + +
+ {/* Current Image Preview */} + {value && ( +
+
+ Uploaded logo +
+ {!disabled && ( + + )} +
+ )} + + {/* Upload Area */} + {!value && ( +
+
+
+
+ {isUploading ? ( +
+ ) : ( + + )} +
+
+ +
+

+ {isUploading + ? "Uploading..." + : "Drop your logo here, or browse"} +

+
+ {acceptedFormats.map(format => ( + + {format.split("/")[1]?.toUpperCase()} + + ))} +
+

+ Max {maxSizeMB}MB +

+
+ + +
+
+ )} + + {/* Replace/Change Button for existing image */} + {value && !disabled && ( + + )} + + {/* Error Message */} + {error && ( +
+ +

{error}

+
+ )} +
+ + {/* Hidden File Input */} + +
+ ); +} diff --git a/src/hooks/use-file-upload.ts b/src/hooks/use-file-upload.ts index 5da475e..23ac5a8 100644 --- a/src/hooks/use-file-upload.ts +++ b/src/hooks/use-file-upload.ts @@ -1,122 +1,122 @@ -import { useState } from "react"; -import { toast } from "sonner"; - -interface UploadOptions { - type?: "organization-logo" | "user-avatar"; - organizationId?: string; - maxSizeMB?: number; - allowedTypes?: string[]; -} - -interface UploadResponse { - success: boolean; - url?: string; - filename?: string; - size?: number; - type?: string; - error?: string; -} - -export function useFileUpload() { - const [isUploading, setIsUploading] = useState(false); - - const uploadFile = async ( - file: File, - options: UploadOptions = {} - ): Promise => { - const { - type = "organization-logo", - organizationId, - maxSizeMB = 5, - allowedTypes = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/webp", - "image/svg+xml", - ], - } = options; - - // Client-side validation - if (!allowedTypes.includes(file.type)) { - toast.error( - `Invalid file type. Please upload ${allowedTypes.map(t => t.split("/")[1]).join(", ")} files.` - ); - return null; - } - - if (file.size > maxSizeMB * 1024 * 1024) { - toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); - return null; - } - - setIsUploading(true); - - try { - const formData = new FormData(); - formData.append("file", file); - formData.append("type", type); - if (organizationId) { - formData.append("organizationId", organizationId); - } - - const response = await fetch("/api/upload", { - method: "POST", - body: formData, - }); - - const result = (await response.json()) as UploadResponse; - - if (!response.ok) { - throw new Error(result.error ?? "Upload failed"); - } - - if (result.success && result.url) { - toast.success("File uploaded successfully"); - return result; - } else { - throw new Error("Upload failed"); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Upload failed"; - toast.error(errorMessage); - return null; - } finally { - setIsUploading(false); - } - }; - - const deleteFile = async (filePath: string): Promise => { - try { - const response = await fetch( - `/api/upload?path=${encodeURIComponent(filePath)}`, - { - method: "DELETE", - } - ); - - const result = (await response.json()) as { - success: boolean; - error?: string; - }; - - if (!response.ok) { - throw new Error(result.error ?? "Delete failed"); - } - - return result.success; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Delete failed"; - toast.error(errorMessage); - return false; - } - }; - - return { - uploadFile, - deleteFile, - isUploading, - }; -} +import { useState } from "react"; +import { toast } from "sonner"; + +interface UploadOptions { + type?: "organization-logo" | "user-avatar"; + organizationId?: string; + maxSizeMB?: number; + allowedTypes?: string[]; +} + +interface UploadResponse { + success: boolean; + url?: string; + filename?: string; + size?: number; + type?: string; + error?: string; +} + +export function useFileUpload() { + const [isUploading, setIsUploading] = useState(false); + + const uploadFile = async ( + file: File, + options: UploadOptions = {} + ): Promise => { + const { + type = "organization-logo", + organizationId, + maxSizeMB = 5, + allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/svg+xml", + ], + } = options; + + // Client-side validation + if (!allowedTypes.includes(file.type)) { + toast.error( + `Invalid file type. Please upload ${allowedTypes.map(t => t.split("/")[1]).join(", ")} files.` + ); + return null; + } + + if (file.size > maxSizeMB * 1024 * 1024) { + toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); + return null; + } + + setIsUploading(true); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + if (organizationId) { + formData.append("organizationId", organizationId); + } + + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + const result = (await response.json()) as UploadResponse; + + if (!response.ok) { + throw new Error(result.error ?? "Upload failed"); + } + + if (result.success && result.url) { + toast.success("File uploaded successfully"); + return result; + } else { + throw new Error("Upload failed"); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Upload failed"; + toast.error(errorMessage); + return null; + } finally { + setIsUploading(false); + } + }; + + const deleteFile = async (filePath: string): Promise => { + try { + const response = await fetch( + `/api/upload?path=${encodeURIComponent(filePath)}`, + { + method: "DELETE", + } + ); + + const result = (await response.json()) as { + success: boolean; + error?: string; + }; + + if (!response.ok) { + throw new Error(result.error ?? "Delete failed"); + } + + return result.success; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Delete failed"; + toast.error(errorMessage); + return false; + } + }; + + return { + uploadFile, + deleteFile, + isUploading, + }; +} diff --git a/src/lib/currency-utils.ts b/src/lib/currency-utils.ts index 2ad80ff..0774162 100644 --- a/src/lib/currency-utils.ts +++ b/src/lib/currency-utils.ts @@ -1,80 +1,80 @@ -/** - * Shared currency formatting utilities for both client and server - */ - -/** - * Format currency values based on currency type - */ -export function formatCurrency( - value: number | string | { toString(): string }, - currency = "USD" -): string { - let numericValue: number; - - if (typeof value === "number") { - numericValue = value; - } else if (typeof value === "string") { - numericValue = parseFloat(value); - if (isNaN(numericValue)) return "0"; - } else if (value && typeof value === "object" && "toString" in value) { - try { - numericValue = parseFloat(value.toString()); - if (isNaN(numericValue)) return "0"; - } catch { - return "0"; - } - } else { - return "0"; - } - - // IDR doesn't use decimal places and uses thousands separators - if (currency === "IDR") { - return new Intl.NumberFormat("id-ID", { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(Math.round(numericValue)); - } - - // For other currencies, use 2 decimal places with appropriate locale - return new Intl.NumberFormat("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(numericValue); -} - -/** - * Get currency symbol for display - */ -export function getCurrencySymbol(currency: string): string { - const symbols: Record = { - USD: "$", - EUR: "€", - GBP: "£", - IDR: "Rp", - JPY: "¥", - CNY: "¥", - AUD: "A$", - CAD: "C$", - }; - - return symbols[currency] ?? currency; -} - -/** - * Format currency with symbol - */ -export function formatCurrencyWithSymbol( - value: number | string | { toString(): string }, - currency = "USD" -): string { - const formattedAmount = formatCurrency(value, currency); - const symbol = getCurrencySymbol(currency); - - // For IDR, put symbol before the amount - if (currency === "IDR") { - return `${symbol} ${formattedAmount}`; - } - - // For most other currencies, put symbol after - return `${formattedAmount} ${symbol}`; -} +/** + * Shared currency formatting utilities for both client and server + */ + +/** + * Format currency values based on currency type + */ +export function formatCurrency( + value: number | string | { toString(): string }, + currency = "USD" +): string { + let numericValue: number; + + if (typeof value === "number") { + numericValue = value; + } else if (typeof value === "string") { + numericValue = parseFloat(value); + if (isNaN(numericValue)) return "0"; + } else if (value && typeof value === "object" && "toString" in value) { + try { + numericValue = parseFloat(value.toString()); + if (isNaN(numericValue)) return "0"; + } catch { + return "0"; + } + } else { + return "0"; + } + + // IDR doesn't use decimal places and uses thousands separators + if (currency === "IDR") { + return new Intl.NumberFormat("id-ID", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(Math.round(numericValue)); + } + + // For other currencies, use 2 decimal places with appropriate locale + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numericValue); +} + +/** + * Get currency symbol for display + */ +export function getCurrencySymbol(currency: string): string { + const symbols: Record = { + USD: "$", + EUR: "€", + GBP: "£", + IDR: "Rp", + JPY: "¥", + CNY: "¥", + AUD: "A$", + CAD: "C$", + }; + + return symbols[currency] ?? currency; +} + +/** + * Format currency with symbol + */ +export function formatCurrencyWithSymbol( + value: number | string | { toString(): string }, + currency = "USD" +): string { + const formattedAmount = formatCurrency(value, currency); + const symbol = getCurrencySymbol(currency); + + // For IDR, put symbol before the amount + if (currency === "IDR") { + return `${symbol} ${formattedAmount}`; + } + + // For most other currencies, put symbol after + return `${formattedAmount} ${symbol}`; +} diff --git a/src/server/api/routers/finance/invoice.ts b/src/server/api/routers/finance/invoice.ts index 2ab1d66..6c6b616 100644 --- a/src/server/api/routers/finance/invoice.ts +++ b/src/server/api/routers/finance/invoice.ts @@ -12,12 +12,12 @@ import { Resend } from "resend"; import { env } from "~/env"; import { render } from "@react-email/components"; import { InvoiceEmail } from "~/server/email/templates/invoice-email"; -<<<<<<< HEAD -import { formatCurrency, formatDecimal } from "~/server/utils/format"; -======= -import { formatDecimalLike } from "~/server/utils/format"; +import { + formatCurrency, + formatDecimal, + formatDecimalLike, +} from "~/server/utils/format"; import { WorkflowEvents } from "~/lib/workflow-dispatcher"; ->>>>>>> upstream/main // Shared schemas for better reusability and consistency const invoiceItemSchema = z.object({ From 3829eea6834af3d6f16911c4e6a3bcfb03c2c572 Mon Sep 17 00:00:00 2001 From: mzcoder-hub Date: Wed, 17 Sep 2025 12:11:22 +0700 Subject: [PATCH 5/9] feat: add organization address field - Add address field to Organization model in Prisma schema - Update organization creation and editing forms to include address input - Add address field to organization info form with textarea component - Update tRPC organization router to handle address field in create/update operations - Update invoice email template to display organization address - Regenerate Zod schemas to include new address field - Ensure address is properly handled in both frontend forms and backend API --- prisma/generated/zod/index.ts | 87 ++++++++++++++++++- prisma/schema.prisma | 1 + .../organizations/add-organization-dialog.tsx | 28 +++++- .../edit-organization-dialog.tsx | 31 ++++++- .../settings/organization-info-form.tsx | 46 +++++++++- src/server/api/routers/finance/invoice.ts | 16 ++-- src/server/api/routers/organization.ts | 7 ++ src/server/email/templates/invoice-email.tsx | 19 ++++ 8 files changed, 217 insertions(+), 18 deletions(-) diff --git a/prisma/generated/zod/index.ts b/prisma/generated/zod/index.ts index 73e3995..3dc08de 100644 --- a/prisma/generated/zod/index.ts +++ b/prisma/generated/zod/index.ts @@ -85,7 +85,7 @@ export const CustomRoleScalarFieldEnumSchema = z.enum(['id','organizationId','na export const CustomRolePermissionScalarFieldEnumSchema = z.enum(['id','customRoleId','permissionId','createdAt']); -export const OrganizationScalarFieldEnumSchema = z.enum(['id','name','logo','website','industry','description','createdAt','updatedAt']); +export const OrganizationScalarFieldEnumSchema = z.enum(['id','name','logo','website','industry','address','description','createdAt','updatedAt']); export const UserOrganizationScalarFieldEnumSchema = z.enum(['userId','organizationId','role','customRoleId','joinedAt']); @@ -453,6 +453,7 @@ export const OrganizationSchema = z.object({ logo: z.string().nullable(), website: z.string().nullable(), industry: z.string().nullable(), + address: z.string().nullable(), description: z.string().nullable(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), @@ -1882,6 +1883,7 @@ export const OrganizationSelectSchema: z.ZodType = z. logo: z.boolean().optional(), website: z.boolean().optional(), industry: z.boolean().optional(), + address: z.boolean().optional(), description: z.boolean().optional(), createdAt: z.boolean().optional(), updatedAt: z.boolean().optional(), @@ -4186,6 +4188,7 @@ export const OrganizationWhereInputSchema: z.ZodType StringNullableFilterSchema),z.string() ]).optional().nullable(), website: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), industry: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), + address: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), description: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), createdAt: z.union([ z.lazy(() => DateTimeFilterSchema),z.coerce.date() ]).optional(), updatedAt: z.union([ z.lazy(() => DateTimeFilterSchema),z.coerce.date() ]).optional(), @@ -4214,6 +4217,7 @@ export const OrganizationOrderByWithRelationInputSchema: z.ZodType SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), website: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), industry: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), + address: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), description: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), createdAt: z.lazy(() => SortOrderSchema).optional(), updatedAt: z.lazy(() => SortOrderSchema).optional(), @@ -4248,6 +4252,7 @@ export const OrganizationWhereUniqueInputSchema: z.ZodType StringNullableFilterSchema),z.string() ]).optional().nullable(), website: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), industry: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), + address: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), description: z.union([ z.lazy(() => StringNullableFilterSchema),z.string() ]).optional().nullable(), createdAt: z.union([ z.lazy(() => DateTimeFilterSchema),z.coerce.date() ]).optional(), updatedAt: z.union([ z.lazy(() => DateTimeFilterSchema),z.coerce.date() ]).optional(), @@ -4276,6 +4281,7 @@ export const OrganizationOrderByWithAggregationInputSchema: z.ZodType SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), website: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), industry: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), + address: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), description: z.union([ z.lazy(() => SortOrderSchema),z.lazy(() => SortOrderInputSchema) ]).optional(), createdAt: z.lazy(() => SortOrderSchema).optional(), updatedAt: z.lazy(() => SortOrderSchema).optional(), @@ -4293,6 +4299,7 @@ export const OrganizationScalarWhereWithAggregatesInputSchema: z.ZodType StringNullableWithAggregatesFilterSchema),z.string() ]).optional().nullable(), website: z.union([ z.lazy(() => StringNullableWithAggregatesFilterSchema),z.string() ]).optional().nullable(), industry: z.union([ z.lazy(() => StringNullableWithAggregatesFilterSchema),z.string() ]).optional().nullable(), + address: z.union([ z.lazy(() => StringNullableWithAggregatesFilterSchema),z.string() ]).optional().nullable(), description: z.union([ z.lazy(() => StringNullableWithAggregatesFilterSchema),z.string() ]).optional().nullable(), createdAt: z.union([ z.lazy(() => DateTimeWithAggregatesFilterSchema),z.coerce.date() ]).optional(), updatedAt: z.union([ z.lazy(() => DateTimeWithAggregatesFilterSchema),z.coerce.date() ]).optional(), @@ -10601,6 +10608,7 @@ export const OrganizationCreateInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -10685,6 +10695,7 @@ export const OrganizationUncheckedUpdateInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -10713,6 +10724,7 @@ export const OrganizationCreateManyInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -10735,6 +10748,7 @@ export const OrganizationUncheckedUpdateManyInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -17674,6 +17688,7 @@ export const OrganizationCountOrderByAggregateInputSchema: z.ZodType SortOrderSchema).optional(), website: z.lazy(() => SortOrderSchema).optional(), industry: z.lazy(() => SortOrderSchema).optional(), + address: z.lazy(() => SortOrderSchema).optional(), description: z.lazy(() => SortOrderSchema).optional(), createdAt: z.lazy(() => SortOrderSchema).optional(), updatedAt: z.lazy(() => SortOrderSchema).optional() @@ -17685,6 +17700,7 @@ export const OrganizationMaxOrderByAggregateInputSchema: z.ZodType SortOrderSchema).optional(), website: z.lazy(() => SortOrderSchema).optional(), industry: z.lazy(() => SortOrderSchema).optional(), + address: z.lazy(() => SortOrderSchema).optional(), description: z.lazy(() => SortOrderSchema).optional(), createdAt: z.lazy(() => SortOrderSchema).optional(), updatedAt: z.lazy(() => SortOrderSchema).optional() @@ -17696,6 +17712,7 @@ export const OrganizationMinOrderByAggregateInputSchema: z.ZodType SortOrderSchema).optional(), website: z.lazy(() => SortOrderSchema).optional(), industry: z.lazy(() => SortOrderSchema).optional(), + address: z.lazy(() => SortOrderSchema).optional(), description: z.lazy(() => SortOrderSchema).optional(), createdAt: z.lazy(() => SortOrderSchema).optional(), updatedAt: z.lazy(() => SortOrderSchema).optional() @@ -27707,6 +27724,7 @@ export const OrganizationCreateWithoutCustomRolesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -27850,6 +27870,7 @@ export const OrganizationUncheckedUpdateWithoutCustomRolesInputSchema: z.ZodType logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -29536,6 +29557,7 @@ export const OrganizationCreateWithoutUsersInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -29664,6 +29688,7 @@ export const OrganizationUncheckedUpdateWithoutUsersInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -29728,6 +29753,7 @@ export const OrganizationCreateWithoutCustomersInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -29985,6 +30013,7 @@ export const OrganizationUncheckedUpdateWithoutCustomersInputSchema: z.ZodType

NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -30397,6 +30426,7 @@ export const OrganizationCreateWithoutProjectsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -30694,6 +30726,7 @@ export const OrganizationUncheckedUpdateWithoutProjectsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -30820,6 +30853,7 @@ export const OrganizationCreateWithoutTasksInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -31204,6 +31240,7 @@ export const OrganizationUncheckedUpdateWithoutTasksInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -31761,6 +31798,7 @@ export const OrganizationCreateWithoutInvoicesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -31993,6 +32033,7 @@ export const OrganizationUncheckedUpdateWithoutInvoicesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -32462,6 +32503,7 @@ export const OrganizationCreateWithoutExpensesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -32645,6 +32689,7 @@ export const OrganizationUncheckedUpdateWithoutExpensesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -32776,6 +32821,7 @@ export const OrganizationCreateWithoutExpenseCategoriesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -32939,6 +32987,7 @@ export const OrganizationUncheckedUpdateWithoutExpenseCategoriesInputSchema: z.Z logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -32982,6 +33031,7 @@ export const OrganizationCreateWithoutExpenseTagsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -33097,6 +33149,7 @@ export const OrganizationUncheckedUpdateWithoutExpenseTagsInputSchema: z.ZodType logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -33667,6 +33720,7 @@ export const OrganizationCreateWithoutInvitationsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -33764,6 +33820,7 @@ export const OrganizationUncheckedUpdateWithoutInvitationsInputSchema: z.ZodType logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -33791,6 +33848,7 @@ export const OrganizationCreateWithoutEmployeesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -34014,6 +34074,7 @@ export const OrganizationUncheckedUpdateWithoutEmployeesInputSchema: z.ZodType

NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -34533,6 +34594,7 @@ export const OrganizationCreateWithoutCampaignsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -34706,6 +34770,7 @@ export const OrganizationUncheckedUpdateWithoutCampaignsInputSchema: z.ZodType

NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -35232,6 +35297,7 @@ export const OrganizationCreateWithoutFinancialReportsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -35435,6 +35503,7 @@ export const OrganizationUncheckedUpdateWithoutFinancialReportsInputSchema: z.Zo logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -35872,6 +35941,7 @@ export const OrganizationCreateWithoutWorkflowsInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -36243,6 +36315,7 @@ export const OrganizationUncheckedUpdateWithoutWorkflowsInputSchema: z.ZodType

NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -38494,6 +38567,7 @@ export const OrganizationCreateWithoutActionTemplatesInputSchema: z.ZodType NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -38621,6 +38697,7 @@ export const OrganizationUncheckedUpdateWithoutActionTemplatesInputSchema: z.Zod logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -38782,6 +38859,7 @@ export const OrganizationCreateWithoutVariableDefinitionsInputSchema: z.ZodType< logo: z.string().optional().nullable(), website: z.string().optional().nullable(), industry: z.string().optional().nullable(), + address: z.string().optional().nullable(), description: z.string().optional().nullable(), createdAt: z.coerce.date().optional(), updatedAt: z.coerce.date().optional(), @@ -38809,6 +38887,7 @@ export const OrganizationUncheckedCreateWithoutVariableDefinitionsInputSchema: z logo: z.string().optional().nullable(), website: z.string().optional().nullable(), industry: z.string().optional().nullable(), + address: z.string().optional().nullable(), description: z.string().optional().nullable(), createdAt: z.coerce.date().optional(), updatedAt: z.coerce.date().optional(), @@ -38918,6 +38997,7 @@ export const OrganizationUpdateWithoutVariableDefinitionsInputSchema: z.ZodType< logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -38945,6 +39025,7 @@ export const OrganizationUncheckedUpdateWithoutVariableDefinitionsInputSchema: z logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -40231,6 +40312,7 @@ export const OrganizationCreateWithoutIntegrationConfigsInputSchema: z.ZodType

NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), @@ -40328,6 +40412,7 @@ export const OrganizationUncheckedUpdateWithoutIntegrationConfigsInputSchema: z. logo: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), website: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), industry: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), + address: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), description: z.union([ z.string(),z.lazy(() => NullableStringFieldUpdateOperationsInputSchema) ]).optional().nullable(), createdAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), updatedAt: z.union([ z.coerce.date(),z.lazy(() => DateTimeFieldUpdateOperationsInputSchema) ]).optional(), diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fb074a..51f69c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,6 +95,7 @@ model Organization { logo String? website String? industry String? + address String? description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/components/organizations/add-organization-dialog.tsx b/src/components/organizations/add-organization-dialog.tsx index 4e1f6cf..e730570 100644 --- a/src/components/organizations/add-organization-dialog.tsx +++ b/src/components/organizations/add-organization-dialog.tsx @@ -24,13 +24,14 @@ import { import { Input } from "~/components/ui/input"; import { Button } from "~/components/ui/button"; import { Textarea } from "~/components/ui/textarea"; -import { ImageUpload } from "~/components/ui/image-upload"; +import { UploadThingImageUpload } from "~/components/ui/uploadthing-image-upload"; const organizationFormSchema = z.object({ name: z.string().min(1, "Organization name is required"), description: z.string().optional(), website: z.string().url().optional().or(z.literal("")), industry: z.string().optional(), + address: z.string().optional(), logo: z.string().optional(), }); @@ -64,6 +65,7 @@ export function AddOrganizationDialog({ description: "", website: "", industry: "", + address: "", logo: "", }, }); @@ -118,13 +120,13 @@ export function AddOrganizationDialog({ render={({ field }) => ( - @@ -189,6 +191,26 @@ export function AddOrganizationDialog({ )} />

+ + ( + + Address + +