From aa2f30c086bc4c81acf7f727f067f2e76cf8a93b Mon Sep 17 00:00:00 2001 From: brian Date: Tue, 27 Jan 2026 12:59:24 -0500 Subject: [PATCH] added idividual vendor pages --- app/(auth)/vendors/[id]/page.tsx | 37 +++ app/api/vendors/[id]/route.ts | 124 +++++++ components/tables/DataTable.tsx | 28 +- components/tables/VendorsTable.tsx | 34 +- components/vendor/ContactCard.tsx | 105 ++++++ components/vendor/EditVendorModal.tsx | 441 +++++++++++++++++++++++++ components/vendor/FinancialCard.tsx | 99 ++++++ components/vendor/NotesCard.tsx | 54 +++ components/vendor/SidebarCard.tsx | 145 ++++++++ components/vendor/VendorDetailPage.tsx | 97 ++++++ components/vendor/VendorHeader.tsx | 67 ++++ 11 files changed, 1212 insertions(+), 19 deletions(-) create mode 100644 app/(auth)/vendors/[id]/page.tsx create mode 100644 app/api/vendors/[id]/route.ts create mode 100644 components/vendor/ContactCard.tsx create mode 100644 components/vendor/EditVendorModal.tsx create mode 100644 components/vendor/FinancialCard.tsx create mode 100644 components/vendor/NotesCard.tsx create mode 100644 components/vendor/SidebarCard.tsx create mode 100644 components/vendor/VendorDetailPage.tsx create mode 100644 components/vendor/VendorHeader.tsx diff --git a/app/(auth)/vendors/[id]/page.tsx b/app/(auth)/vendors/[id]/page.tsx new file mode 100644 index 0000000..1c28947 --- /dev/null +++ b/app/(auth)/vendors/[id]/page.tsx @@ -0,0 +1,37 @@ +// app/(auth)/vendors/[id]/page.tsx +import { notFound } from 'next/navigation' +import { prisma } from '@/lib/prisma' +import { VendorDetailPage } from '@/components/vendor/VendorDetailPage' + +interface PageProps { + params: { + id: string + } +} + +export default async function Page({ params }: PageProps) { + const { id } = params + + const vendor = await prisma.vendor.findUnique({ + where: { id }, + include: { + address: true, + events: { + select: { + id: true, + name: true, + date: true, + }, + orderBy: { + date: 'asc', + }, + }, + }, + }) + + if (!vendor) { + notFound() + } + + return +} \ No newline at end of file diff --git a/app/api/vendors/[id]/route.ts b/app/api/vendors/[id]/route.ts new file mode 100644 index 0000000..dd79e2e --- /dev/null +++ b/app/api/vendors/[id]/route.ts @@ -0,0 +1,124 @@ +// app/api/vendors/[id]/route.ts (PUT handler) +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { VendorType, VendorStatus } from '@prisma/client' + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = params.id + const body = await request.json() + + // Check if vendor exists + const existingVendor = await prisma.vendor.findUnique({ + where: { id }, + include: { address: true } + }) + + if (!existingVendor) { + return NextResponse.json( + { error: 'Vendor not found' }, + { status: 404 } + ) + } + + // Parse numeric values + const parseFloatOrNull = (value: any): number | null => { + if (value === null || value === undefined || value === '') return null + const num = parseFloat(value) + return isNaN(num) ? null : num + } + + const parseDateOrNull = (value: any): Date | null => { + if (!value) return null + const date = new Date(value) + return isNaN(date.getTime()) ? null : date + } + + // Handle address update/creation + let addressId = existingVendor.addressId + const hasAddress = body.street && body.city && body.state && body.postalCode + + if (hasAddress) { + if (existingVendor.address) { + // Update existing address + await prisma.address.update({ + where: { id: existingVendor.addressId! }, + data: { + street: body.street, + city: body.city, + state: body.state, + zip: parseInt(body.postalCode), + } + }) + } else { + // Create new address + const newAddress = await prisma.address.create({ + data: { + street: body.street, + city: body.city, + state: body.state, + zip: parseInt(body.postalCode), + } + }) + addressId = newAddress.id + } + } else if (existingVendor.address) { + // Remove address if it existed but now doesn't + await prisma.address.delete({ + where: { id: existingVendor.addressId! } + }) + addressId = null + } + + // Prepare update data + const updateData: any = { + name: body.name, + type: body.type as VendorType, + description: body.description || null, + website: body.website || null, + contactPerson: body.contactPerson || null, + email: body.email || null, + phone: body.phone || null, + status: (body.status as VendorStatus) || 'CONTACTING', + isBooked: Boolean(body.isBooked), + bookedDate: parseDateOrNull(body.bookedDate), + quotedPrice: parseFloatOrNull(body.quotedPrice), + finalCost: parseFloatOrNull(body.finalCost), + depositPaid: parseFloatOrNull(body.depositPaid), + depositDueDate: parseDateOrNull(body.depositDueDate), + finalPaymentDue: parseDateOrNull(body.finalPaymentDue), + paymentNotes: body.paymentNotes || null, + contractUrl: body.contractUrl || null, + proposalUrl: body.proposalUrl || null, + notes: body.notes || null, + } + + // Only include addressId if we have one + if (addressId) { + updateData.addressId = addressId + } else { + updateData.addressId = null + } + + // Update vendor + const updatedVendor = await prisma.vendor.update({ + where: { id }, + data: updateData, + include: { + address: !!addressId + } + }) + + return NextResponse.json(updatedVendor) + + } catch (error: any) { + console.error('Error updating vendor:', error) + return NextResponse.json( + { error: 'Failed to update vendor', details: error.message }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/components/tables/DataTable.tsx b/components/tables/DataTable.tsx index cad908b..a8e9ebc 100644 --- a/components/tables/DataTable.tsx +++ b/components/tables/DataTable.tsx @@ -24,12 +24,16 @@ interface DataTableProps { columns: ColumnDef[] data: TData[] className?: string + onRowClick?: (rowData: TData) => void + getRowId?: (row: TData) => string } export function DataTable({ columns, data, - className + className, + onRowClick, + getRowId }: DataTableProps) { const [sorting, setSorting] = useState([]) @@ -39,15 +43,22 @@ export function DataTable({ state: { sorting }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel() + getSortedRowModel: getSortedRowModel(), + getRowId: getRowId || ((row: any) => row.id?.toString() || Math.random().toString()), }) + const handleRowClick = (row: TData) => { + if (onRowClick) { + onRowClick(row) + } + } + return (
{table.getHeaderGroups().map(headerGroup => ( - + {headerGroup.headers.map(header => ( {header.isPlaceholder @@ -65,7 +76,12 @@ export function DataTable({ {table.getRowModel().rows.length ? ( table.getRowModel().rows.map(row => ( - + handleRowClick(row.original)} + className={onRowClick ? "cursor-pointer hover:bg-accent/50 transition-colors" : ""} + data-state={row.getIsSelected() ? "selected" : ""} + > {row.getVisibleCells().map(cell => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -75,7 +91,7 @@ export function DataTable({ )) ) : ( - + No results @@ -84,4 +100,4 @@ export function DataTable({
) -} +} \ No newline at end of file diff --git a/components/tables/VendorsTable.tsx b/components/tables/VendorsTable.tsx index 7d6b962..6852a7a 100644 --- a/components/tables/VendorsTable.tsx +++ b/components/tables/VendorsTable.tsx @@ -7,9 +7,10 @@ import { Button } from '../ui/button' import DialogWrapper from '../dialogs/DialogWrapper' import CreateVendorForm from '../forms/CreateVendorForm' import { useState } from 'react' -import { fetchVendorsClient } from '@/lib/helper/fetchVendors' // You'll need to create this +import { fetchVendorsClient } from '@/lib/helper/fetchVendors' import { Vendor, VendorType, VendorStatus } from '@prisma/client' -import { Badge } from '../ui/badge' // You'll need Badge component +import { Badge } from '../ui/badge' +import { useRouter } from 'next/navigation' interface VendorRow { id: string @@ -81,9 +82,9 @@ const columns: ColumnDef[] = [
{row.original.name}
{row.original.contactPerson && ( -
- {row.original.contactPerson} -
+
+ {row.original.contactPerson} +
)}
), @@ -103,10 +104,10 @@ const columns: ColumnDef[] = [ cell: ({ row }) => (
{row.original.email && ( -
{row.original.email}
+
{row.original.email}
)} {row.original.phone && ( -
{row.original.phone}
+
{row.original.phone}
)}
), @@ -175,6 +176,7 @@ const columns: ColumnDef[] = [ export default function VendorsTable({ initialVendors }: Props) { const [isDialogOpen, setIsDialogOpen] = useState(false) const [vendors, setVendors] = useState(initialVendors) + const router = useRouter() async function refreshVendors() { try { @@ -185,6 +187,11 @@ export default function VendorsTable({ initialVendors }: Props) { } } + // Handle row click + const handleRowClick = (vendor: VendorRow) => { + router.push(`/vendors/${vendor.id}`) + } + return (
@@ -204,6 +211,7 @@ export default function VendorsTable({ initialVendors }: Props) { { - await refreshVendors() - setIsDialogOpen(false) - }} - /> + { + await refreshVendors() + setIsDialogOpen(false) + }} + /> } />
diff --git a/components/vendor/ContactCard.tsx b/components/vendor/ContactCard.tsx new file mode 100644 index 0000000..0c207cd --- /dev/null +++ b/components/vendor/ContactCard.tsx @@ -0,0 +1,105 @@ +// components/vendors/ContactCard.tsx +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { Globe, Mail, MapPin, Phone, User } from 'lucide-react' + +interface Address { + street: string + city: string + state: string + zip: number +} + +interface ContactCardProps { + contactPerson?: string | null + email?: string | null + phone?: string | null + website?: string | null + address?: Address | null +} + +export function ContactCard({ contactPerson, email, phone, website, address }: ContactCardProps) { + return ( + + + Contact Information + + +
+ {contactPerson && ( +
+ +
+

Contact Person

+

{contactPerson}

+
+
+ )} + + {email && ( +
+ +
+

Email

+ + {email} + +
+
+ )} + + {phone && ( +
+ +
+

Phone

+ + {phone} + +
+
+ )} + + {website && ( + + )} +
+ + {address && ( + <> + +
+ +
+

Address

+

+ {address.street}
+ {address.city}, {address.state} {address.zip} +

+
+
+ + )} +
+
+ ) +} \ No newline at end of file diff --git a/components/vendor/EditVendorModal.tsx b/components/vendor/EditVendorModal.tsx new file mode 100644 index 0000000..dc0e7e2 --- /dev/null +++ b/components/vendor/EditVendorModal.tsx @@ -0,0 +1,441 @@ +// components/vendors/EditVendorModal.tsx +'use client' + +import { useState } from 'react' +import { Vendor, VendorType, VendorStatus } from '@prisma/client' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { toast } from 'sonner' + +interface EditVendorModalProps { + vendor: { + id: string + name: string + type: VendorType + description?: string | null + website?: string | null + contactPerson?: string | null + email?: string | null + phone?: string | null + address?: { + street: string + city: string + state: string + zip: number + } | null + status: VendorStatus + isBooked: boolean + bookedDate?: Date | null + quotedPrice?: number | null + finalCost?: number | null + depositPaid?: number | null + depositDueDate?: Date | null + finalPaymentDue?: Date | null + paymentNotes?: string | null + contractUrl?: string | null + proposalUrl?: string | null + notes?: string | null + } + isOpen: boolean + onClose: () => void + onSave: (updatedVendor: any) => Promise +} + +const VENDOR_TYPES = Object.values(VendorType) +const VENDOR_STATUSES = Object.values(VendorStatus) + +export function EditVendorModal({ vendor, isOpen, onClose, onSave }: EditVendorModalProps) { + const [loading, setLoading] = useState(false) + const [formData, setFormData] = useState({ + name: vendor.name, + type: vendor.type, + description: vendor.description || '', + website: vendor.website || '', + contactPerson: vendor.contactPerson || '', + email: vendor.email || '', + phone: vendor.phone || '', + street: vendor.address?.street || '', + city: vendor.address?.city || '', + state: vendor.address?.state || '', + postalCode: vendor.address?.zip?.toString() || '', + status: vendor.status, + isBooked: vendor.isBooked, + bookedDate: vendor.bookedDate ? new Date(vendor.bookedDate).toISOString().split('T')[0] : '', + quotedPrice: vendor.quotedPrice?.toString() || '', + finalCost: vendor.finalCost?.toString() || '', + depositPaid: vendor.depositPaid?.toString() || '', + depositDueDate: vendor.depositDueDate ? new Date(vendor.depositDueDate).toISOString().split('T')[0] : '', + finalPaymentDue: vendor.finalPaymentDue ? new Date(vendor.finalPaymentDue).toISOString().split('T')[0] : '', + paymentNotes: vendor.paymentNotes || '', + contractUrl: vendor.contractUrl || '', + proposalUrl: vendor.proposalUrl || '', + notes: vendor.notes || '', + }) + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value + })) + } + + const handleSelectChange = (name: string, value: string) => { + setFormData(prev => ({ ...prev, [name]: value })) + } + + const handleCheckboxChange = (name: string, checked: boolean) => { + setFormData(prev => ({ ...prev, [name]: checked })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + const response = await fetch(`/api/vendors/${vendor.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }) + + if (!response.ok) throw new Error('Failed to update vendor') + + const updatedVendor = await response.json() + await onSave(updatedVendor) + toast.success('Vendor updated successfully!') + onClose() + } catch (error) { + console.error('Error updating vendor:', error) + toast.error('Failed to update vendor') + } finally { + setLoading(false) + } + } + + return ( + + + + Edit Vendor + + Update the vendor information below + + + +
+
+ {/* Basic Information */} +
+
+ + +
+ +
+ + +
+ +
+ +