diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..01f406c Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index d8b694d..a7af738 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules/ data/postgres/ uploads/ data/ -docker-compose.override.yml \ No newline at end of file +docker-compose.override.yml +.vscode \ No newline at end of file diff --git a/app/(auth)/vendors/page.tsx b/app/(auth)/vendors/page.tsx new file mode 100644 index 0000000..50309e7 --- /dev/null +++ b/app/(auth)/vendors/page.tsx @@ -0,0 +1,13 @@ +import VendorsTable from '@/components/tables/VendorsTable' +import { queries } from '@/lib/queries' +import React from 'react' + +export default async function VendorPage() { + const vendors = await queries.fetchAllVendors() + + return ( +
+ +
+ ) +} diff --git a/app/api/vendors/create/route.ts b/app/api/vendors/create/route.ts new file mode 100644 index 0000000..526a280 --- /dev/null +++ b/app/api/vendors/create/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { VendorType, VendorStatus } from '@prisma/client' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + console.log('Received data:', body) // Debug logging + + // Validate required fields + if (!body.name || !body.type) { + return NextResponse.json( + { error: 'Name and vendor type are required' }, + { status: 400 } + ) + } + + // Check if we have address data + const hasAddress = body.street && body.city && body.state && body.postalCode + + // 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 + } + + // Prepare vendor data WITHOUT addressId + const vendorData: 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, + } + + // Add address relation if we have address data + if (hasAddress) { + vendorData.address = { + create: { + street: body.street, + city: body.city, + state: body.state, + zip: parseInt(body.postalCode), + } + } + } + // If no address, don't include address field at all + + console.log('Creating vendor with data:', vendorData) // Debug logging + + const vendor = await prisma.vendor.create({ + data: vendorData, + include: { + address: hasAddress // Only include address if we created one + } + }) + + return NextResponse.json(vendor, { status: 201 }) + + } catch (error: any) { + console.error('Error creating vendor:', error) + + // Provide helpful error messages + if (error.code === 'P2002') { + return NextResponse.json( + { error: 'A vendor with this name already exists' }, + { status: 409 } + ) + } + + if (error.code === 'P2023') { + return NextResponse.json( + { error: 'Invalid data format provided' }, + { status: 400 } + ) + } + + return NextResponse.json( + { + error: 'Failed to create vendor', + details: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/vendors/fetch/route.ts b/app/api/vendors/fetch/route.ts new file mode 100644 index 0000000..31cd229 --- /dev/null +++ b/app/api/vendors/fetch/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET(request: NextRequest) { + try { + const vendors = await prisma.vendor.findMany({ + include: { + address: true, + }, + orderBy: { + createdAt: 'desc', + }, + }) + + return NextResponse.json(vendors) + + } catch (error) { + console.error('Error fetching vendors:', error) + return NextResponse.json( + { error: 'Failed to fetch vendors' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..3b30317 Binary files /dev/null and b/bun.lockb differ diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 6912bb8..6699a80 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -13,6 +13,7 @@ import { IconSearch, IconSettings, IconUsers, + IconUsersGroup } from "@tabler/icons-react" import { NavMain } from "@/components/nav-main" @@ -51,6 +52,11 @@ const data = { url: "/venues", icon: IconBuildingArch, }, + { + title: "Vendors", + url: "/vendors", + icon: IconUsersGroup, + }, ], // navClouds: [ // { diff --git a/components/forms/CreateVendorForm.tsx b/components/forms/CreateVendorForm.tsx new file mode 100644 index 0000000..c6c6128 --- /dev/null +++ b/components/forms/CreateVendorForm.tsx @@ -0,0 +1,449 @@ +'use client' + +import React, { useState } from 'react' +import { Input } from '../ui/input' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { toast } from 'sonner' +import { Address, Vendor } from '@prisma/client' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { Textarea } from '../ui/textarea' +import { Checkbox } from '../ui/checkbox' + +interface CreateVendorFormProps { + onSuccess?: (newVendorId?: string) => void +} + +const VENDOR_TYPES = [ + 'VENUE', 'CATERER', 'PHOTOGRAPHER', 'VIDEOGRAPHER', 'FLORIST', 'BAKERY', + 'DJ', 'BAND', 'OFFICIANT', 'HAIR_MAKEUP', 'TRANSPORTATION', 'RENTALS', + 'DECOR', 'PLANNER', 'STATIONERY', 'OTHER' +] as const + +const VENDOR_STATUSES = [ + 'RESEARCHING', 'CONTACTING', 'RESPONDED', 'PROPOSAL_RECEIVED', + 'NEGOTIATING', 'CONTRACT_SENT', 'CONTRACT_SIGNED', 'DECLINED', 'BACKUP' +] as const + + +const defaultFormValues = { + name: '', + type: '' as typeof VENDOR_TYPES[number], + description: '', + website: '', + + contactPerson: '', + email: '', + phone: '', + + street: '', + city: '', + state: '', + postalCode: '', + country: 'United States', + + status: 'CONTACTING' as typeof VENDOR_STATUSES[number], + isBooked: false, + bookedDate: '', + + quotedPrice: '', + finalCost: '', + depositPaid: '', + depositDueDate: '', + finalPaymentDue: '', + paymentNotes: '', + + notes: '', + contractUrl: '', + proposalUrl: '', +} + +export default function CreateVendorForm({ onSuccess }: CreateVendorFormProps) { + const [formData, setFormData] = useState(defaultFormValues) + const [loading, setLoading] = useState(false) + + function handleChange(e: React.ChangeEvent) { + const { name, value, type } = e.target + setFormData(prev => ({ + ...prev, + [name]: type === 'number' ? parseFloat(value) || 0 : value + })) + } + + function handleSelectChange(name: string, value: string) { + setFormData(prev => ({ ...prev, [name]: value })) + } + + function handleCheckboxChange(name: string, checked: boolean) { + setFormData(prev => ({ ...prev, [name]: checked })) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + if (!formData.name || !formData.type) { + toast.error('Please fill in required fields (Name and Type)') + return + } + + setLoading(true) + try { + const res = await fetch('/api/vendors/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...formData, + quotedPrice: formData.quotedPrice ? parseFloat(formData.quotedPrice) : null, + finalCost: formData.finalCost ? parseFloat(formData.finalCost) : null, + depositPaid: formData.depositPaid ? parseFloat(formData.depositPaid) : null, + bookedDate: formData.bookedDate || null, + depositDueDate: formData.depositDueDate || null, + finalPaymentDue: formData.finalPaymentDue || null, + }) + }) + + if (!res.ok) { + const error = await res.json() + throw new Error(error.message || 'Failed to create vendor') + } + + const data = await res.json() + const newVendorId = data?.id + + toast.success('Vendor created successfully!') + setFormData(defaultFormValues) + + if (onSuccess) onSuccess(newVendorId) + } catch (err) { + console.error(err) + toast.error(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setLoading(false) + } + } + + return ( +
+
+ {/* Basic Information */} +
+

Basic Information

+ +
+ + +
+ +
+ + +
+ +
+ +