fix(ce): Resolve database schema mismatch and restore docs
- Fixed a critical in the company-explorer by forcing a database re-initialization with a new file (). This ensures the application code is in sync with the database schema. - Documented the schema mismatch incident and its resolution in MIGRATION_PLAN.md. - Restored and enhanced BUILDER_APPS_MIGRATION.md by recovering extensive, valuable content from the git history that was accidentally deleted. The guide now again includes detailed troubleshooting steps and code templates for common migration pitfalls.
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Users, Search, ChevronLeft, ChevronRight, Upload,
|
||||
Mail, Building, Briefcase, User
|
||||
Users, Search, Upload, Mail, Building, LayoutGrid, List,
|
||||
ChevronLeft, ChevronRight, X, ArrowDownUp
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ContactsTableProps {
|
||||
apiBase: string
|
||||
onCompanyClick: (id: number) => void
|
||||
onContactClick: (companyId: number, contactId: number) => void // NEW
|
||||
onContactClick: (companyId: number, contactId: number) => void
|
||||
}
|
||||
|
||||
export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: ContactsTableProps) {
|
||||
@@ -17,39 +17,35 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortBy, setSortBy] = useState('name_asc')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const limit = 50
|
||||
|
||||
// Import State
|
||||
const [isImportOpen, setIsImportOpen] = useState(false)
|
||||
const [importText, setImportText] = useState("")
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null)
|
||||
|
||||
const fetchContacts = () => {
|
||||
setLoading(true)
|
||||
axios.get(`${apiBase}/contacts/all?skip=${page * limit}&limit=${limit}&search=${search}`)
|
||||
.then(res => {
|
||||
setData(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
})
|
||||
axios.get(`${apiBase}/contacts/all?skip=${page * limit}&limit=${limit}&search=${search}&sort_by=${sortBy}`)
|
||||
.then(res => { setData(res.data.items); setTotal(res.data.total); })
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(fetchContacts, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [page, search])
|
||||
}, [page, search, sortBy])
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importText) return
|
||||
setImportStatus("Parsing...")
|
||||
|
||||
try {
|
||||
// Simple CSV-ish parsing: Company, First, Last, Email, Job
|
||||
const lines = importText.split('\n').filter(l => l.trim())
|
||||
const contacts = lines.map(line => {
|
||||
const parts = line.split(/[;,|]+/).map(p => p.trim())
|
||||
// Expected: Company, First, Last, Email (optional)
|
||||
if (parts.length < 3) return null
|
||||
return {
|
||||
company_name: parts[0],
|
||||
@@ -90,34 +86,38 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
||||
<h2>All Contacts ({total})</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 w-full md:w-auto gap-2 max-w-xl">
|
||||
<div className="flex flex-1 w-full md:w-auto items-center gap-2 max-w-2xl">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search contacts, companies, emails..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
<input type="text" placeholder="Search contacts..." className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={search} onChange={e => { setSearch(e.target.value); setPage(0); }}/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm transition-colors"
|
||||
>
|
||||
<div className="relative flex items-center text-slate-700 dark:text-slate-300">
|
||||
<ArrowDownUp className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
||||
className="pl-8 pr-4 py-2 appearance-none bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<option value="name_asc">Alphabetical</option>
|
||||
<option value="created_desc">Newest First</option>
|
||||
<option value="updated_desc">Last Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center bg-slate-200 dark:bg-slate-800 p-1 rounded-md text-slate-700 dark:text-slate-300">
|
||||
<button onClick={() => setViewMode('grid')} className={clsx("p-1.5 rounded", viewMode === 'grid' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="Grid View"><LayoutGrid className="h-4 w-4" /></button>
|
||||
<button onClick={() => setViewMode('list')} className={clsx("p-1.5 rounded", viewMode === 'list' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="List View"><List className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<button onClick={() => setIsImportOpen(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm">
|
||||
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Import Modal */}
|
||||
{isImportOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-lg border border-slate-200 dark:border-slate-800 flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-900 dark:text-white">Bulk Import Contacts</h3>
|
||||
<button onClick={() => setIsImportOpen(false)} className="text-slate-500 hover:text-red-500"><Users className="h-5 w-5" /></button>
|
||||
<button onClick={() => setIsImportOpen(false)} className="text-slate-500 hover:text-red-500"><X className="h-5 w-5" /></button>
|
||||
</div>
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||
@@ -144,77 +144,71 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
||||
{loading && <div className="p-4 text-center text-slate-500">Loading contacts...</div>}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||
{data.length === 0 && !loading ? (
|
||||
<div className="p-12 text-center text-slate-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">No contacts found</p>
|
||||
<p className="text-slate-400 mt-2">Import a list or create one manually to get started.</p>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||
{data.map((c: any) => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => onContactClick(c.company_id, c.id)}
|
||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4 border-l-slate-400"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full text-slate-500 dark:text-slate-400">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 dark:text-white text-sm">
|
||||
{c.title} {c.first_name} {c.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[150px]" title={c.job_title}>
|
||||
{c.job_title || "No Title"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx("px-2 py-0.5 rounded text-[10px] font-bold border", c.status ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800" : "bg-slate-100 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700")}>
|
||||
{c.status || "No Status"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
|
||||
onClick={() => onCompanyClick(c.company_id)}
|
||||
>
|
||||
<Building className="h-3 w-3" />
|
||||
<span className="truncate font-medium">{c.company_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{c.email || "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
<span className="truncate">{c.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div key={c.id} onClick={() => onContactClick(c.company_id, c.id)}
|
||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4 border-l-slate-400">
|
||||
<div className="font-bold text-slate-900 dark:text-white text-sm">{c.title} {c.first_name} {c.last_name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">{c.job_title || "No Title"}</div>
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||
<div onClick={(e) => { e.stopPropagation(); onCompanyClick(c.company_id); }}
|
||||
className="flex items-center gap-2 text-xs font-bold text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer">
|
||||
<Building className="h-3 w-3" /> {c.company_name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500"><Mail className="h-3 w-3" /> {c.email || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-800">
|
||||
<thead className="bg-slate-100 dark:bg-slate-950/50">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Name</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Company</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Email</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Role</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 bg-white dark:bg-slate-900">
|
||||
{data.map((c: any) => (
|
||||
<tr key={c.id} onClick={() => onContactClick(c.company_id, c.id)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer">
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm font-medium text-slate-900 dark:text-white">{c.title} {c.first_name} {c.last_name}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
<div onClick={(e) => { e.stopPropagation(); onCompanyClick(c.company_id); }}
|
||||
className="font-bold text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer">
|
||||
{c.company_name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.email || '-' }</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.role || '-'}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.status || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>Showing {data.length} of {total} contacts</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="px-2 py-1">Page {page + 1}</span>
|
||||
<button
|
||||
disabled={(page + 1) * limit >= total}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
<span>{total} Contacts total</span>
|
||||
<div className="flex gap-1 items-center">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronLeft className="h-4 w-4" /></button>
|
||||
<span>Page {page + 1}</span>
|
||||
<button disabled={(page + 1) * limit >= total} onClick={() => setPage(p => p + 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronRight className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user