Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening

Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+250
View File
@@ -0,0 +1,250 @@
"use client"
import { useState, useRef, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Camera, Loader2, X, Home } from "lucide-react"
import { updateBranding } from "@/app/actions/branding"
const HEX_RE = /^#[0-9a-fA-F]{6}$/
interface Props {
brandName: string | null
brandLogoUrl: string | null
brandColor: string | null
hidePoweredBy: boolean
}
export function BrandingForm({ brandName, brandLogoUrl, brandColor, hidePoweredBy }: Props) {
const router = useRouter()
const fileRef = useRef<HTMLInputElement>(null)
const [isPending, startTransition] = useTransition()
const [name, setName] = useState(brandName ?? "")
const [logoUrl, setLogoUrl] = useState<string | null>(brandLogoUrl)
const [color, setColor] = useState(brandColor ?? "#4f46e5")
const [hidePb, setHidePb] = useState(hidePoweredBy)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState("")
const validColor = HEX_RE.test(color)
const previewColor = validColor ? color : "#4f46e5"
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
const labelClass = "mb-1.5 block text-sm font-medium text-white/70"
async function handleLogo(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith("image/")) {
toast.error("Please select an image file")
return
}
if (file.size > 5 * 1024 * 1024) {
toast.error("Logo must be under 5MB")
return
}
setUploading(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("scope", "misc")
const res = await fetch("/api/upload", { method: "POST", body: fd })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(typeof data.error === "string" ? data.error : "Upload failed")
}
const { url } = await res.json()
setLogoUrl(url)
toast.success("Logo uploaded")
} catch (err: any) {
toast.error(err?.message ?? "Upload failed")
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ""
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError("")
if (color.trim() && !validColor) {
setError("Accent color must be a hex value like #4f46e5")
return
}
const fd = new FormData()
fd.set("brand_name", name)
fd.set("brand_logo_url", logoUrl ?? "")
fd.set("brand_color", color.trim() && validColor ? color : "")
if (hidePb) fd.set("hide_powered_by", "on")
startTransition(async () => {
try {
await updateBranding(fd)
toast.success("Branding saved")
router.refresh()
} catch (err: any) {
const msg = err?.message ?? "Failed to save branding"
setError(msg)
toast.error(msg)
}
})
}
return (
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
{error && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
)}
<div>
<label className={labelClass}>Brand Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={60}
placeholder="Acme Property Management"
className={inputClass}
/>
<p className="mt-1 text-xs text-white/30">Shown in the tenant portal header instead of Tenant Portal.</p>
</div>
<div>
<label className={labelClass}>Logo</label>
{logoUrl ? (
<div className="group relative h-28 w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoUrl} alt="Brand logo" className="h-full w-full object-contain p-3" />
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 transition group-hover:opacity-100">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg bg-white/10 px-3 py-2 text-xs text-white transition hover:bg-white/20"
>
<Camera className="h-3.5 w-3.5" /> Change
</button>
<button
type="button"
onClick={() => setLogoUrl(null)}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg bg-red-500/20 px-3 py-2 text-xs text-red-400 transition hover:bg-red-500/30"
>
<X className="h-3.5 w-3.5" /> Remove
</button>
</div>
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
<Loader2 className="h-6 w-6 animate-spin text-white" />
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="flex h-28 w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] text-white/30 transition hover:border-indigo-500/40 hover:text-white/50 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<Camera className="h-6 w-6" />
<span className="text-xs">Upload logo</span>
</>
)}
</button>
)}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleLogo} />
<p className="mt-1 text-xs text-white/30">PNG or SVG works best. Max 5MB.</p>
</div>
<div>
<label className={labelClass}>Accent Color</label>
<div className="flex items-center gap-3">
<input
type="color"
value={previewColor}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-12 shrink-0 cursor-pointer rounded-lg border border-white/10 bg-white/5 p-1"
aria-label="Accent color picker"
/>
<input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#4f46e5"
className={inputClass + (color.trim() && !validColor ? " border-red-500/40" : "")}
/>
</div>
<p className="mt-1 text-xs text-white/30">Used for highlights in the tenant portal. Hex like #RRGGBB.</p>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
<input
type="checkbox"
checked={hidePb}
onChange={(e) => setHidePb(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-indigo-600"
/>
<span>
<span className="block text-sm font-medium text-white">Remove Powered by branding</span>
<span className="mt-0.5 block text-xs text-white/40">
Hides the Powered by Property Management Network line in the tenant portal.
</span>
</span>
</label>
<button
type="submit"
disabled={isPending || uploading}
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{isPending ? "Saving..." : "Save Branding"}
</button>
</form>
{/* Live preview */}
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-white/30">Preview</p>
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="mb-4 flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl"
style={{ backgroundColor: previewColor }}
>
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt="Logo" className="h-full w-full object-contain p-1" />
) : (
<Home className="h-5 w-5 text-white" />
)}
</div>
<div>
<p className="text-xs text-white/40">{name.trim() || "Tenant Portal"}</p>
<h1 className="text-lg font-bold text-white">Jane Tenant</h1>
</div>
</div>
<div className="rounded-lg border border-white/[0.06] p-3">
<div className="flex items-center gap-2 text-sm" style={{ color: previewColor }}>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: previewColor }} />
<span className="font-medium">Rent History</span>
</div>
</div>
{!hidePb && (
<p className="mt-4 text-center text-[10px] text-white/20">
Powered by Property Management Network
</p>
)}
</div>
</div>
</div>
)
}
+91 -13
View File
@@ -3,33 +3,111 @@
import { useState } from "react"
import { cn } from "@/lib/utils"
export function CheckoutButton({ plan, label, highlight }: { plan: string; label: string; highlight?: boolean }) {
export function CheckoutButton({
plan,
label,
highlight,
interval = "month",
annualAvailable = false,
paypalEnabled = false,
}: {
plan: string
label: string
highlight?: boolean
interval?: "month" | "year"
// When true, show a monthly/annual choice. Only pass this for subscription
// plans and only when annual billing is actually configured server-side.
annualAvailable?: boolean
// When true, also offer "Pay with PayPal" using the same interval choice.
paypalEnabled?: boolean
}) {
const [loading, setLoading] = useState(false)
const [paypalLoading, setPaypalLoading] = useState(false)
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
const effectiveInterval = annualAvailable ? chosenInterval : interval
async function handleClick() {
setLoading(true)
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan }),
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else setLoading(false)
}
async function handlePaypal() {
setPaypalLoading(true)
const res = await fetch("/api/paypal/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else {
setPaypalLoading(false)
if (data.error) alert(data.error)
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight
? "bg-indigo-600 text-white hover:bg-indigo-500"
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
<div className="space-y-2">
{annualAvailable && (
<div className="flex rounded-lg border border-white/10 p-0.5 text-[11px] font-medium">
<button
type="button"
onClick={() => setChosenInterval("month")}
className={cn(
"flex-1 rounded-md py-1 transition",
chosenInterval === "month" ? "bg-white/10 text-white" : "text-white/40 hover:text-white/70"
)}
>
Monthly
</button>
<button
type="button"
onClick={() => setChosenInterval("year")}
className={cn(
"flex-1 rounded-md py-1 transition",
chosenInterval === "year" ? "bg-white/10 text-white" : "text-white/40 hover:text-white/70"
)}
>
Annual
</button>
</div>
)}
>
{loading ? "Loading..." : label}
</button>
<button
onClick={handleClick}
disabled={loading || paypalLoading}
className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight
? "bg-indigo-600 text-white hover:bg-indigo-500"
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
)}
>
{loading ? "Loading..." : label}
</button>
{paypalEnabled && (
<button
onClick={handlePaypal}
disabled={loading || paypalLoading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
>
{paypalLoading ? (
"Loading..."
) : (
<>
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
</>
)}
</button>
)}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Trash2 } from "lucide-react"
import { toast } from "sonner"
import { ConfirmModal } from "@/components/ui/confirm-modal"
interface DeleteTenantButtonProps {
tenantId: string
tenantName?: string
/** When set, refresh in place instead of navigating to the tenants list. */
refreshOnly?: boolean
/** Compact icon-only variant for table rows. */
compact?: boolean
}
export function DeleteTenantButton({ tenantId, tenantName, refreshOnly, compact }: DeleteTenantButtonProps) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
async function handleDelete() {
setLoading(true)
try {
const res = await fetch(`/api/tenants/${tenantId}`, { method: "DELETE" })
if (!res.ok) {
const data = await res.json().catch(() => null)
toast.error(data?.error ?? "Failed to delete tenant")
return
}
toast.success(`${tenantName ?? "Tenant"} deleted`)
if (refreshOnly) {
router.refresh()
} else {
router.push("/tenants")
router.refresh()
}
} catch {
toast.error("Network error — please try again")
} finally {
setLoading(false)
setOpen(false)
}
}
return (
<>
{compact ? (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpen(true) }}
className="text-white/20 hover:text-red-400 transition"
title="Delete tenant"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-2 text-sm text-red-400 hover:border-red-500/40 hover:bg-red-500/5 transition"
>
<Trash2 className="h-3.5 w-3.5" /> Delete
</button>
)}
<ConfirmModal
open={open}
title="Delete tenant?"
description={`Are you sure you want to delete ${tenantName ?? "this tenant"}? This frees their unit and cannot be undone.`}
confirmLabel="Delete"
loading={loading}
onConfirm={handleDelete}
onCancel={() => setOpen(false)}
/>
</>
)
}
+107
View File
@@ -0,0 +1,107 @@
"use client"
import { useTransition } from "react"
import { toast } from "sonner"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
type Req = {
id: string
provider: string
status: string
signer_email: string
document_name: string | null
sent_at: string | null
completed_at: string | null
last_error: string | null
}
type Prov = { id: string; label: string; configured: boolean }
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
signed: { label: "Signed", cls: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20", Icon: CheckCircle2 },
declined: { label: "Declined", cls: "text-red-400 bg-red-500/10 border-red-500/20", Icon: XCircle },
voided: { label: "Voided", cls: "text-white/40 bg-white/5 border-white/10", Icon: XCircle },
error: { label: "Failed", cls: "text-red-400 bg-red-500/10 border-red-500/20", Icon: AlertTriangle },
}
const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
export function EsignLease({
leaseId,
providers,
requests,
canSend,
disabledReason,
}: {
leaseId: string
providers: Prov[]
requests: Req[]
canSend: boolean
disabledReason: string
}) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition()
function send(provider: string) {
start(async () => {
try {
await sendLeaseForSignatureAction(leaseId, provider)
toast.success("Lease sent for signature")
} catch (e) {
toast.error((e as Error).message || "Couldn't send for signature")
}
})
}
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="mb-3 flex items-center gap-2">
<PenLine className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">E-signature</h3>
</div>
{requests.length > 0 && (
<ul className="mb-4 space-y-2">
{requests.map((r) => {
const s = STATUS[r.status] ?? STATUS.sent
return (
<li key={r.id} className="flex items-center justify-between gap-3 rounded-xl border border-white/[0.05] bg-white/[0.02] px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-white/80">{PROVIDER_LABEL[r.provider] ?? r.provider} · {r.signer_email}</p>
<p className="text-[11px] text-white/35">
Sent {r.sent_at ? formatDate(r.sent_at) : "—"}
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p>
</div>
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
<s.Icon className="h-3 w-3" /> {s.label}
</span>
</li>
)
})}
</ul>
)}
{configured.length === 0 ? (
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p>
) : canSend ? (
<div className="flex flex-wrap gap-2">
{configured.map((p) => (
<button
key={p.id}
onClick={() => send(p.id)}
disabled={pending}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
<PenLine className="h-3.5 w-3.5" /> Send via {p.label}
</button>
))}
</div>
) : (
<p className="text-xs text-white/30">{disabledReason}</p>
)}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { toast } from "sonner"
import { Pencil, Ban, Trash2 } from "lucide-react"
import { ConfirmModal } from "@/components/ui/confirm-modal"
type Action = "terminate" | "delete" | null
export function LeaseActions({ lease }: { lease: any }) {
const router = useRouter()
const [pending, setPending] = useState<Action>(null)
const [loading, setLoading] = useState(false)
async function handleTerminate() {
setLoading(true)
try {
const res = await fetch(`/api/leases/${lease.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "terminated" }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
toast.error(typeof data?.error === "string" ? data.error : "Could not terminate lease")
return
}
toast.success("Lease terminated")
setPending(null)
router.refresh()
} catch {
toast.error("Something went wrong")
} finally {
setLoading(false)
}
}
async function handleDelete() {
setLoading(true)
try {
const res = await fetch(`/api/leases/${lease.id}`, { method: "DELETE" })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
toast.error(typeof data?.error === "string" ? data.error : "Could not delete lease")
return
}
toast.success("Lease deleted")
setPending(null)
router.push("/leases")
router.refresh()
} catch {
toast.error("Something went wrong")
} finally {
setLoading(false)
}
}
return (
<>
<div className="flex items-center gap-2">
<Link
href={`/leases/${lease.id}/edit`}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Link>
{lease.status !== "terminated" && (
<button
onClick={() => setPending("terminate")}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-amber-400/80 transition hover:border-amber-500/30 hover:text-amber-400"
>
<Ban className="h-3.5 w-3.5" />
Terminate
</button>
)}
<button
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/40 transition hover:border-red-500/30 hover:text-red-400"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
<ConfirmModal
open={pending === "terminate"}
variant="warning"
title="Terminate this lease?"
description="The lease will be marked as terminated. You can still view it afterwards."
confirmLabel="Terminate"
loading={loading}
onConfirm={handleTerminate}
onCancel={() => !loading && setPending(null)}
/>
<ConfirmModal
open={pending === "delete"}
variant="danger"
title="Delete this lease?"
description="This permanently removes the lease record. This action cannot be undone."
confirmLabel="Delete"
loading={loading}
onConfirm={handleDelete}
onCancel={() => !loading && setPending(null)}
/>
</>
)
}
+1 -1
View File
@@ -79,7 +79,7 @@ export function LeaseForm({ tenants, properties, lease, prefill }: {
setIsDirty(false)
toast.success(lease ? "Lease updated" : "Lease created")
router.push("/leases")
router.push(lease ? `/leases/${lease.id}` : "/leases")
router.refresh()
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { useState } from "react"
export function PaypalCancelButton() {
const [loading, setLoading] = useState(false)
async function handleClick() {
if (!confirm("Cancel your PayPal subscription? You'll keep access until the end of the current billing period.")) {
return
}
setLoading(true)
const res = await fetch("/api/paypal/cancel", { method: "POST" })
const data = await res.json().catch(() => ({}))
if (res.ok) {
window.location.href = "/settings/billing?canceled=true"
} else {
setLoading(false)
alert(data.error || "Could not cancel the subscription.")
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
>
{loading ? "Canceling..." : "Cancel Subscription"}
</button>
)
}
+19
View File
@@ -31,6 +31,20 @@ export function RentActions({ payment }: { payment: any }) {
router.refresh()
}
async function sendLink() {
setLoading(true)
const res = await fetch("/api/rent/send-payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_id: payment.id }),
})
const data = await res.json().catch(() => ({}))
setLoading(false)
if (res.ok) toast.success(data.message ?? "Payment link emailed to tenant")
else toast.error(data.error ?? "Failed to send payment link")
router.refresh()
}
if (loading) return <span className="text-xs text-white/30">Updating...</span>
return (
@@ -40,6 +54,11 @@ export function RentActions({ payment }: { payment: any }) {
Mark Paid
</button>
)}
{payment.status !== "paid" && (
<button onClick={sendLink} className="text-xs text-indigo-400 hover:text-indigo-300">
Send link
</button>
)}
{payment.status !== "overdue" && payment.status !== "paid" && (
<button onClick={() => markAs("overdue")} className="text-xs text-red-400 hover:text-red-300">
Mark Overdue
+79
View File
@@ -0,0 +1,79 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Pencil, Trash2 } from "lucide-react"
import { toast } from "sonner"
interface UnitActionsProps {
propertyId: string
unitId: string
unitNumber: string
}
export function UnitActions({ propertyId, unitId, unitNumber }: UnitActionsProps) {
const router = useRouter()
const [confirming, setConfirming] = useState(false)
const [loading, setLoading] = useState(false)
async function handleDelete() {
setLoading(true)
try {
const res = await fetch(`/api/units/${unitId}`, { method: "DELETE" })
if (!res.ok) {
const data = await res.json().catch(() => null)
toast.error(data?.error ?? "Failed to delete unit")
return
}
toast.success(`Unit ${unitNumber} deleted`)
router.refresh()
} catch {
toast.error("Network error — please try again")
} finally {
setLoading(false)
setConfirming(false)
}
}
if (confirming) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-white/50">Delete?</span>
<button
onClick={handleDelete}
disabled={loading}
className="rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition"
>
{loading ? "Deleting…" : "Yes"}
</button>
<button
onClick={() => setConfirming(false)}
disabled={loading}
className="rounded-lg border border-white/10 px-2.5 py-1.5 text-xs text-white/60 hover:text-white transition"
>
Cancel
</button>
</div>
)
}
return (
<div className="flex items-center gap-1">
<Link
href={`/properties/${propertyId}/units/${unitId}/edit`}
className="rounded-lg border border-white/10 p-1.5 text-white/40 hover:border-white/20 hover:text-white transition"
title="Edit unit"
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<button
onClick={() => setConfirming(true)}
className="rounded-lg border border-red-500/20 p-1.5 text-red-400/80 hover:border-red-500/40 hover:text-red-400 hover:bg-red-500/5 transition"
title="Delete unit"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
+24 -18
View File
@@ -4,6 +4,7 @@ import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Select } from "@/components/ui/select"
import type { Unit } from "@/types"
const statusOptions = [
{ value: "vacant", label: "Vacant" },
@@ -14,13 +15,15 @@ const statusOptions = [
interface UnitFormProps {
propertyId: string
unit?: Unit
onSuccess?: () => void
}
export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
export function UnitForm({ propertyId, unit, onSuccess }: UnitFormProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState("vacant")
const [status, setStatus] = useState(unit?.status ?? "vacant")
const isEditing = Boolean(unit)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -38,21 +41,24 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
notes: formData.get("notes") || undefined,
}
const res = await fetch("/api/units", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const res = await fetch(
isEditing ? `/api/units/${unit!.id}` : "/api/units",
{
method: isEditing ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
)
setLoading(false)
if (!res.ok) {
const data = await res.json()
toast.error(data?.error ?? "Failed to add unit")
const data = await res.json().catch(() => null)
toast.error(data?.error ?? (isEditing ? "Failed to update unit" : "Failed to add unit"))
return
}
toast.success("Unit added")
toast.success(isEditing ? "Unit updated" : "Unit added")
if (onSuccess) {
onSuccess()
} else {
@@ -69,26 +75,26 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Unit Number *</label>
<input name="unit_number" required placeholder="e.g. 1A, 101" className={inputClass} />
<input name="unit_number" required placeholder="e.g. 1A, 101" defaultValue={unit?.unit_number ?? ""} className={inputClass} />
</div>
<div>
<label className={labelClass}>Monthly Rent ($) *</label>
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" className={inputClass} />
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" defaultValue={unit?.rent_amount ?? ""} className={inputClass} />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass}>Bedrooms</label>
<input name="bedrooms" type="number" min={0} defaultValue={1} className={inputClass} />
<input name="bedrooms" type="number" min={0} defaultValue={unit?.bedrooms ?? 1} className={inputClass} />
</div>
<div>
<label className={labelClass}>Bathrooms</label>
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={1} className={inputClass} />
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={unit?.bathrooms ?? 1} className={inputClass} />
</div>
<div>
<label className={labelClass}>Sq Ft</label>
<input name="sq_ft" type="number" min={0} placeholder="Optional" className={inputClass} />
<input name="sq_ft" type="number" min={0} placeholder="Optional" defaultValue={unit?.sq_ft ?? ""} className={inputClass} />
</div>
</div>
@@ -97,13 +103,13 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
<Select
options={statusOptions}
value={status}
onChange={setStatus}
onChange={(v) => setStatus(v as "vacant" | "occupied" | "maintenance" | "unavailable")}
/>
</div>
<div>
<label className={labelClass}>Notes</label>
<textarea name="notes" rows={3} placeholder="Optional notes..." className={`${inputClass} resize-none`} />
<textarea name="notes" rows={3} placeholder="Optional notes..." defaultValue={unit?.notes ?? ""} className={`${inputClass} resize-none`} />
</div>
<div className="flex gap-3 pt-2">
@@ -119,7 +125,7 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
disabled={loading}
className="flex-1 rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
>
{loading ? "Adding…" : "Add Unit"}
{loading ? (isEditing ? "Saving…" : "Adding…") : isEditing ? "Save Changes" : "Add Unit"}
</button>
</div>
</form>