added idividual vendor pages

This commit is contained in:
2026-01-27 12:59:24 -05:00
parent 3aa9b6f325
commit aa2f30c086
11 changed files with 1212 additions and 19 deletions

37
app/(auth)/vendors/[id]/page.tsx vendored Normal file
View File

@@ -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 <VendorDetailPage vendor={vendor} />
}

124
app/api/vendors/[id]/route.ts vendored Normal file
View File

@@ -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 }
)
}
}

View File

@@ -24,12 +24,16 @@ interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
className?: string
onRowClick?: (rowData: TData) => void
getRowId?: (row: TData) => string
}
export function DataTable<TData, TValue>({
columns,
data,
className
className,
onRowClick,
getRowId
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
@@ -39,9 +43,16 @@ export function DataTable<TData, TValue>({
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 (
<div className={`rounded-md border ${className || ''}`}>
<Table>
@@ -65,7 +76,12 @@ export function DataTable<TData, TValue>({
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
<TableRow
key={row.id}
onClick={() => handleRowClick(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-accent/50 transition-colors" : ""}
data-state={row.getIsSelected() ? "selected" : ""}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -75,7 +91,7 @@ export function DataTable<TData, TValue>({
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
<TableCell colSpan={columns.length} className="text-center h-24">
No results
</TableCell>
</TableRow>

View File

@@ -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
@@ -175,6 +176,7 @@ const columns: ColumnDef<VendorRow>[] = [
export default function VendorsTable({ initialVendors }: Props) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [vendors, setVendors] = useState<VendorRow[]>(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 (
<div className="space-y-4">
<div className="flex justify-between items-center">
@@ -204,6 +211,7 @@ export default function VendorsTable({ initialVendors }: Props) {
<DataTable
columns={columns}
data={vendors}
onRowClick={handleRowClick}
/>
<DialogWrapper

105
components/vendor/ContactCard.tsx vendored Normal file
View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{contactPerson && (
<div className="flex items-start gap-3">
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Contact Person</p>
<p className="font-medium">{contactPerson}</p>
</div>
</div>
)}
{email && (
<div className="flex items-start gap-3">
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Email</p>
<a
href={`mailto:${email}`}
className="font-medium hover:text-primary hover:underline"
>
{email}
</a>
</div>
</div>
)}
{phone && (
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Phone</p>
<a
href={`tel:${phone}`}
className="font-medium hover:text-primary hover:underline"
>
{phone}
</a>
</div>
</div>
)}
{website && (
<div className="flex items-start gap-3">
<Globe className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Website</p>
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="font-medium hover:text-primary hover:underline"
>
{website.replace(/^https?:\/\//, '')}
</a>
</div>
</div>
)}
</div>
{address && (
<>
<Separator />
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Address</p>
<p className="font-medium">
{address.street}<br />
{address.city}, {address.state} {address.zip}
</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
)
}

441
components/vendor/EditVendorModal.tsx vendored Normal file
View File

@@ -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<void>
}
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<HTMLInputElement | HTMLTextAreaElement>) => {
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Vendor</DialogTitle>
<DialogDescription>
Update the vendor information below
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Basic Information */}
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">Vendor Name *</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="type">Vendor Type *</Label>
<Select
value={formData.type}
onValueChange={(value) => handleSelectChange('type', value)}
required
>
<SelectTrigger>
<SelectValue placeholder="Select vendor type" />
</SelectTrigger>
<SelectContent>
{VENDOR_TYPES.map(type => (
<SelectItem key={type} value={type}>
{type.charAt(0) + type.slice(1).toLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
/>
</div>
<div className="space-y-1">
<Label htmlFor="website">Website</Label>
<Input
id="website"
name="website"
value={formData.website}
onChange={handleChange}
type="url"
/>
</div>
</div>
{/* Contact Information */}
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="contactPerson">Contact Person</Label>
<Input
id="contactPerson"
name="contactPerson"
value={formData.contactPerson}
onChange={handleChange}
/>
</div>
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
value={formData.email}
onChange={handleChange}
type="email"
/>
</div>
<div className="space-y-1">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
type="tel"
/>
</div>
<div className="space-y-1">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => handleSelectChange('status', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{VENDOR_STATUSES.map(status => (
<SelectItem key={status} value={status}>
{status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Address Section */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Address (Optional)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="street">Street Address</Label>
<Input
id="street"
name="street"
value={formData.street}
onChange={handleChange}
/>
</div>
<div className="space-y-1">
<Label htmlFor="city">City</Label>
<Input
id="city"
name="city"
value={formData.city}
onChange={handleChange}
/>
</div>
<div className="space-y-1">
<Label htmlFor="state">State</Label>
<Input
id="state"
name="state"
value={formData.state}
onChange={handleChange}
/>
</div>
<div className="space-y-1">
<Label htmlFor="postalCode">Postal Code</Label>
<Input
id="postalCode"
name="postalCode"
value={formData.postalCode}
onChange={handleChange}
/>
</div>
</div>
</div>
{/* Financial Information */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Financial Information</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1">
<Label htmlFor="quotedPrice">Quoted Price ($)</Label>
<Input
id="quotedPrice"
name="quotedPrice"
value={formData.quotedPrice}
onChange={handleChange}
type="number"
step="0.01"
/>
</div>
<div className="space-y-1">
<Label htmlFor="finalCost">Final Cost ($)</Label>
<Input
id="finalCost"
name="finalCost"
value={formData.finalCost}
onChange={handleChange}
type="number"
step="0.01"
/>
</div>
<div className="space-y-1">
<Label htmlFor="depositPaid">Deposit Paid ($)</Label>
<Input
id="depositPaid"
name="depositPaid"
value={formData.depositPaid}
onChange={handleChange}
type="number"
step="0.01"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="depositDueDate">Deposit Due Date</Label>
<Input
id="depositDueDate"
name="depositDueDate"
value={formData.depositDueDate}
onChange={handleChange}
type="date"
/>
</div>
<div className="space-y-1">
<Label htmlFor="finalPaymentDue">Final Payment Due</Label>
<Input
id="finalPaymentDue"
name="finalPaymentDue"
value={formData.finalPaymentDue}
onChange={handleChange}
type="date"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="paymentNotes">Payment Notes</Label>
<Textarea
id="paymentNotes"
name="paymentNotes"
value={formData.paymentNotes}
onChange={handleChange}
rows={2}
/>
</div>
</div>
{/* Additional Information */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Additional Information</h3>
<div className="space-y-1">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleChange}
rows={3}
/>
</div>
<div className="space-y-1">
<Label htmlFor="contractUrl">Contract URL</Label>
<Input
id="contractUrl"
name="contractUrl"
value={formData.contractUrl}
onChange={handleChange}
type="url"
/>
</div>
<div className="space-y-1">
<Label htmlFor="proposalUrl">Proposal URL</Label>
<Input
id="proposalUrl"
name="proposalUrl"
value={formData.proposalUrl}
onChange={handleChange}
type="url"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isBooked"
checked={formData.isBooked}
onCheckedChange={(checked) => handleCheckboxChange('isBooked', checked as boolean)}
/>
<Label htmlFor="isBooked" className="cursor-pointer">
Vendor is booked
</Label>
</div>
{formData.isBooked && (
<div className="space-y-1">
<Label htmlFor="bookedDate">Booked Date</Label>
<Input
id="bookedDate"
name="bookedDate"
value={formData.bookedDate}
onChange={handleChange}
type="date"
/>
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

99
components/vendor/FinancialCard.tsx vendored Normal file
View File

@@ -0,0 +1,99 @@
// components/vendors/FinancialCard.tsx
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Calendar } from 'lucide-react'
interface FinancialCardProps {
quotedPrice?: number | null
finalCost?: number | null
depositPaid?: number | null
depositDueDate?: Date | null
finalPaymentDue?: Date | null
paymentNotes?: string | null
}
const formatCurrency = (amount?: number | null): string => {
if (!amount) return '—'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
}
const formatDate = (date?: Date | null): string => {
if (!date) return '—'
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function FinancialCard({
quotedPrice,
finalCost,
depositPaid,
depositDueDate,
finalPaymentDue,
paymentNotes,
}: FinancialCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>Financial Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Quoted Price</p>
<p className="text-2xl font-bold">{formatCurrency(quotedPrice)}</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Final Cost</p>
<p className="text-2xl font-bold">{formatCurrency(finalCost)}</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Deposit Paid</p>
<p className="text-2xl font-bold">{formatCurrency(depositPaid)}</p>
</div>
</div>
{(depositDueDate || finalPaymentDue) && (
<>
<Separator className="my-4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{depositDueDate && (
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Deposit Due Date</p>
<p className="font-medium">{formatDate(depositDueDate)}</p>
</div>
</div>
)}
{finalPaymentDue && (
<div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm text-muted-foreground">Final Payment Due</p>
<p className="font-medium">{formatDate(finalPaymentDue)}</p>
</div>
</div>
)}
</div>
</>
)}
{paymentNotes && (
<>
<Separator className="my-4" />
<div>
<p className="text-sm text-muted-foreground mb-2">Payment Notes</p>
<p className="text-sm">{paymentNotes}</p>
</div>
</>
)}
</CardContent>
</Card>
)
}

54
components/vendor/NotesCard.tsx vendored Normal file
View File

@@ -0,0 +1,54 @@
// components/vendors/NotesCard.tsx
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { FileText } from 'lucide-react'
interface NotesCardProps {
notes?: string | null
contractUrl?: string | null
proposalUrl?: string | null
}
export function NotesCard({ notes, contractUrl, proposalUrl }: NotesCardProps) {
if (!notes && !contractUrl && !proposalUrl) return null
return (
<Card>
<CardHeader>
<CardTitle>Notes & Documents</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{notes && (
<div>
<p className="text-sm text-muted-foreground mb-2">Notes</p>
<p className="text-sm whitespace-pre-wrap">{notes}</p>
</div>
)}
{(contractUrl || proposalUrl) && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Documents</p>
<div className="flex flex-wrap gap-2">
{contractUrl && (
<Button variant="outline" size="sm" asChild>
<a href={contractUrl} target="_blank" rel="noopener noreferrer">
<FileText className="h-4 w-4 mr-2" />
View Contract
</a>
</Button>
)}
{proposalUrl && (
<Button variant="outline" size="sm" asChild>
<a href={proposalUrl} target="_blank" rel="noopener noreferrer">
<FileText className="h-4 w-4 mr-2" />
View Proposal
</a>
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
)
}

145
components/vendor/SidebarCard.tsx vendored Normal file
View File

@@ -0,0 +1,145 @@
// components/vendors/SidebarCards.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
interface SidebarCardsProps {
description?: string | null
events: Array<{
id: string
name: string
date: Date | null
}>
timeline: {
createdAt: Date
bookedDate?: Date | null
depositDueDate?: Date | null
finalPaymentDue?: Date | null
}
vendorId: string
}
const formatDate = (date?: Date | null): string => {
if (!date) return '—'
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function SidebarCards({ description, events, timeline, vendorId }: SidebarCardsProps) {
return (
<div className="space-y-6">
{/* Description */}
{description && (
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{description}</p>
</CardContent>
</Card>
)}
{/* Associated Events */}
{events.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Associated Events</CardTitle>
<CardDescription>
{events.length} event{events.length !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{events.map((event) => (
<Link
key={event.id}
href={`/events/${event.id}`}
className="flex items-center justify-between p-3 rounded-lg border hover:bg-accent transition-colors"
>
<div>
<p className="font-medium">{event.name}</p>
{event.date && (
<p className="text-sm text-muted-foreground">
{formatDate(event.date)}
</p>
)}
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Status Timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm">Created</span>
<span className="text-sm text-muted-foreground">
{formatDate(timeline.createdAt)}
</span>
</div>
{timeline.bookedDate && (
<div className="flex items-center justify-between">
<span className="text-sm">Booked</span>
<span className="text-sm text-muted-foreground">
{formatDate(timeline.bookedDate)}
</span>
</div>
)}
{timeline.depositDueDate && (
<div className="flex items-center justify-between">
<span className="text-sm">Deposit Due</span>
<span className="text-sm text-muted-foreground">
{formatDate(timeline.depositDueDate)}
</span>
</div>
)}
{timeline.finalPaymentDue && (
<div className="flex items-center justify-between">
<span className="text-sm">Final Payment Due</span>
<span className="text-sm text-muted-foreground">
{formatDate(timeline.finalPaymentDue)}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/vendors/${vendorId}/edit`}>
Edit Vendor Details
</Link>
</Button>
<Button variant="outline" className="w-full justify-start">
Send Email
</Button>
<Button variant="outline" className="w-full justify-start">
Add Note
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/vendors/${vendorId}/payments`}>
Record Payment
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}

97
components/vendor/VendorDetailPage.tsx vendored Normal file
View File

@@ -0,0 +1,97 @@
// components/vendors/VendorDetailPage.tsx
'use client'
import { useState } from 'react'
import { Vendor, VendorType, VendorStatus } from '@prisma/client'
import { VendorHeader } from './VendorHeader'
import { ContactCard } from './ContactCard'
import { FinancialCard } from './FinancialCard'
import { NotesCard } from './NotesCard'
import { SidebarCards } from './SidebarCard'
import { EditVendorModal } from './EditVendorModal'
interface VendorWithEvents extends Vendor {
address: {
street: string
city: string
state: string
zip: number
} | null
events: Array<{
id: string
name: string
date: Date | null
}>
}
interface VendorDetailPageProps {
vendor: VendorWithEvents
}
export function VendorDetailPage({ vendor }: VendorDetailPageProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [currentVendor, setCurrentVendor] = useState(vendor)
const handleSave = async (updatedVendor: any) => {
setCurrentVendor(updatedVendor)
}
return (
<>
<div className="container mx-auto py-6 space-y-6">
<VendorHeader
vendor={currentVendor}
onEditClick={() => setIsEditModalOpen(true)}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Info */}
<div className="lg:col-span-2 space-y-6">
<ContactCard
contactPerson={currentVendor.contactPerson}
email={currentVendor.email}
phone={currentVendor.phone}
website={currentVendor.website}
address={currentVendor.address}
/>
<FinancialCard
quotedPrice={currentVendor.quotedPrice}
finalCost={currentVendor.finalCost}
depositPaid={currentVendor.depositPaid}
depositDueDate={currentVendor.depositDueDate}
finalPaymentDue={currentVendor.finalPaymentDue}
paymentNotes={currentVendor.paymentNotes}
/>
<NotesCard
notes={currentVendor.notes}
contractUrl={currentVendor.contractUrl}
proposalUrl={currentVendor.proposalUrl}
/>
</div>
{/* Right Column - Sidebar */}
<SidebarCards
description={currentVendor.description}
events={currentVendor.events}
timeline={{
createdAt: currentVendor.createdAt,
bookedDate: currentVendor.bookedDate,
depositDueDate: currentVendor.depositDueDate,
finalPaymentDue: currentVendor.finalPaymentDue,
}}
vendorId={currentVendor.id}
/>
</div>
</div>
<EditVendorModal
vendor={currentVendor}
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
onSave={handleSave}
/>
</>
)
}

67
components/vendor/VendorHeader.tsx vendored Normal file
View File

@@ -0,0 +1,67 @@
// components/vendors/VendorHeader.tsx
import { Vendor, VendorType, VendorStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
interface VendorHeaderProps {
vendor: {
id: string
name: string
type: VendorType
status: VendorStatus
isBooked: boolean
createdAt: Date
}
onEditClick: () => void
}
const formatVendorType = (type: VendorType): string => {
return type.charAt(0) + type.slice(1).toLowerCase()
}
const formatStatus = (status: VendorStatus): { label: string, color: string } => {
const statusMap: Record<VendorStatus, { label: string, color: string }> = {
RESEARCHING: { label: 'Researching', color: 'bg-gray-100 text-gray-800' },
CONTACTING: { label: 'Contacting', color: 'bg-blue-100 text-blue-800' },
RESPONDED: { label: 'Responded', color: 'bg-yellow-100 text-yellow-800' },
PROPOSAL_RECEIVED: { label: 'Proposal Received', color: 'bg-purple-100 text-purple-800' },
NEGOTIATING: { label: 'Negotiating', color: 'bg-orange-100 text-orange-800' },
CONTRACT_SENT: { label: 'Contract Sent', color: 'bg-indigo-100 text-indigo-800' },
CONTRACT_SIGNED: { label: 'Contract Signed', color: 'bg-green-100 text-green-800' },
DECLINED: { label: 'Declined', color: 'bg-red-100 text-red-800' },
BACKUP: { label: 'Backup', color: 'bg-slate-100 text-slate-800' },
}
return statusMap[status]
}
export function VendorHeader({ vendor, onEditClick }: VendorHeaderProps) {
const status = formatStatus(vendor.status)
return (
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold">{vendor.name}</h1>
<Badge className={status.color}>{status.label}</Badge>
{vendor.isBooked && (
<Badge variant="default">Booked</Badge>
)}
</div>
<p className="text-muted-foreground">
{formatVendorType(vendor.type)} Added {new Date(vendor.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onEditClick}>
Edit Vendor
</Button>
<Button asChild>
<Link href="/vendors">
Back to Vendors
</Link>
</Button>
</div>
</div>
)
}