Initial import: property management SaaS + security hardening + admin dashboard
Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, Trash2, Phone, Mail, Wrench, X, Pencil, Check } from "lucide-react"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const TRADES = [
|
||||
{ value: "plumber", label: "Plumber" },
|
||||
{ value: "electrician", label: "Electrician" },
|
||||
{ value: "hvac", label: "HVAC" },
|
||||
{ value: "handyman", label: "Handyman" },
|
||||
{ value: "cleaner", label: "Cleaner" },
|
||||
{ value: "landscaper", label: "Landscaper" },
|
||||
{ value: "roofer", label: "Roofer" },
|
||||
{ value: "painter", label: "Painter" },
|
||||
{ value: "contractor", label: "Contractor" },
|
||||
{ value: "other", label: "Other" },
|
||||
]
|
||||
|
||||
const tradeColors: Record<string, string> = {
|
||||
plumber: "text-blue-400 bg-blue-500/10",
|
||||
electrician: "text-yellow-400 bg-yellow-500/10",
|
||||
hvac: "text-cyan-400 bg-cyan-500/10",
|
||||
handyman: "text-orange-400 bg-orange-500/10",
|
||||
cleaner: "text-green-400 bg-green-500/10",
|
||||
landscaper: "text-emerald-400 bg-emerald-500/10",
|
||||
roofer: "text-amber-400 bg-amber-500/10",
|
||||
painter: "text-purple-400 bg-purple-500/10",
|
||||
contractor: "text-indigo-400 bg-indigo-500/10",
|
||||
other: "text-white/40 bg-white/5",
|
||||
}
|
||||
|
||||
export function VendorManager({ vendors: initial, properties }: { vendors: any[]; properties: any[] }) {
|
||||
const router = useRouter()
|
||||
const [vendors, setVendors] = useState(initial)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState<any>({})
|
||||
const [form, setForm] = useState({ name: "", trade: "handyman", phone: "", email: "", notes: "", property_id: "" })
|
||||
|
||||
const propertyOptions = [
|
||||
{ value: "", label: "All properties" },
|
||||
...properties.map((p: any) => ({ value: p.id, label: p.name })),
|
||||
]
|
||||
|
||||
async function addVendor() {
|
||||
if (!form.name.trim()) return
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/vendors", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
if (!res.ok) { toast.error(data.error ?? "Failed to add vendor"); return }
|
||||
setVendors(v => [...v, data])
|
||||
setForm({ name: "", trade: "handyman", phone: "", email: "", notes: "", property_id: "" })
|
||||
setShowForm(false)
|
||||
toast.success("Vendor added")
|
||||
}
|
||||
|
||||
function startEdit(v: any) {
|
||||
setEditingId(v.id)
|
||||
setEditForm({ name: v.name, trade: v.trade, phone: v.phone ?? "", email: v.email ?? "", notes: v.notes ?? "" })
|
||||
}
|
||||
|
||||
async function saveEdit(id: string) {
|
||||
const res = await fetch(`/api/vendors/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editForm),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
|
||||
setVendors(v => v.map(x => x.id === id ? { ...x, ...data } : x))
|
||||
setEditingId(null)
|
||||
toast.success("Vendor updated")
|
||||
}
|
||||
|
||||
async function deleteVendor(id: string) {
|
||||
await fetch(`/api/vendors/${id}`, { method: "DELETE" })
|
||||
setVendors(v => v.filter(x => x.id !== id))
|
||||
toast.success("Vendor removed")
|
||||
}
|
||||
|
||||
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Add button */}
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 transition hover:shadow-lg hover:shadow-indigo-500/25"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add Vendor
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
{showForm && (
|
||||
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-white">New Vendor</p>
|
||||
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/40 mb-1 block">Name *</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="John's Plumbing" className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-white/40 mb-1 block">Trade *</label>
|
||||
<Select value={form.trade} onChange={v => setForm(f => ({ ...f, trade: v }))} options={TRADES} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/40 mb-1 block">Phone</label>
|
||||
<input value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1 555 000 0000" className={cls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-white/40 mb-1 block">Email</label>
|
||||
<input value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} placeholder="vendor@email.com" className={cls} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-white/40 mb-1 block">Property (optional)</label>
|
||||
<Select value={form.property_id} onChange={v => setForm(f => ({ ...f, property_id: v }))} options={propertyOptions} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-white/40 mb-1 block">Notes</label>
|
||||
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="Reliable, good rates..." className={cls} />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
|
||||
<button onClick={addVendor} disabled={loading || !form.name.trim()} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
|
||||
{loading ? "Adding…" : "Add Vendor"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vendor list */}
|
||||
{vendors.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
|
||||
<Wrench className="h-8 w-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-sm text-white/30">No vendors yet</p>
|
||||
<p className="text-xs text-white/20 mt-1">Add contractors and service providers for quick access</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vendors.map((v: any) => (
|
||||
<div key={v.id} className="rounded-xl border border-white/[0.06] bg-[#16161f] p-4">
|
||||
{editingId === v.id ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input value={editForm.name} onChange={e => setEditForm((f: any) => ({ ...f, name: e.target.value }))} placeholder="Name" className={cls} />
|
||||
<Select value={editForm.trade} onChange={(val: string) => setEditForm((f: any) => ({ ...f, trade: val }))} options={TRADES} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input value={editForm.phone} onChange={e => setEditForm((f: any) => ({ ...f, phone: e.target.value }))} placeholder="Phone" className={cls} />
|
||||
<input value={editForm.email} onChange={e => setEditForm((f: any) => ({ ...f, email: e.target.value }))} placeholder="Email" className={cls} />
|
||||
</div>
|
||||
<input value={editForm.notes} onChange={e => setEditForm((f: any) => ({ ...f, notes: e.target.value }))} placeholder="Notes" className={cls} />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setEditingId(null)} className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-white/40 hover:text-white transition">Cancel</button>
|
||||
<button onClick={() => saveEdit(v.id)} className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500 transition">
|
||||
<Check className="h-3 w-3" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-xs font-bold uppercase ${tradeColors[v.trade] ?? tradeColors.other}`}>
|
||||
{v.name[0]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-white">{v.name}</p>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${tradeColors[v.trade] ?? tradeColors.other}`}>{v.trade}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 flex-wrap">
|
||||
{v.phone && <span className="flex items-center gap-1 text-xs text-white/40"><Phone className="h-3 w-3" />{v.phone}</span>}
|
||||
{v.email && <span className="flex items-center gap-1 text-xs text-white/40"><Mail className="h-3 w-3" />{v.email}</span>}
|
||||
</div>
|
||||
{v.notes && <p className="text-xs text-white/25 mt-1 italic">{v.notes}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => startEdit(v)} className="p-1.5 text-white/20 hover:text-indigo-400 transition rounded-lg hover:bg-indigo-500/10">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => deleteVendor(v.id)} className="p-1.5 text-white/20 hover:text-red-400 transition rounded-lg hover:bg-red-500/10">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user