mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAssociation } from "@/contexts/AssociationContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2, Check, ArrowUp, ArrowDown, Copy } from "lucide-react";
|
||||
|
||||
type Lot = {
|
||||
id: string; association_id: string; lot_number: string; lot_type: string;
|
||||
size: string | null; monthly_rate: number | null; status: string; notes: string | null;
|
||||
};
|
||||
type Rental = {
|
||||
id: string; association_id: string; lot_id: string; renter_name: string;
|
||||
renter_email: string | null; renter_phone: string | null; vehicle_description: string | null;
|
||||
start_date: string; end_date: string | null; monthly_rate: number | null; status: string; notes: string | null;
|
||||
is_owner?: boolean | null; user_id?: string | null;
|
||||
};
|
||||
type WaitlistEntry = {
|
||||
id: string; association_id: string; position: number;
|
||||
requester_name: string; requester_email: string | null; requester_phone: string | null;
|
||||
unit_address: string | null; requested_lot_type: string;
|
||||
vehicle_description: string | null; notes: string | null;
|
||||
status: string; source: string; joined_at: string;
|
||||
fulfilled_at: string | null; fulfilled_lot_number: string | null;
|
||||
};
|
||||
|
||||
export default function RVBoatLotsPage() {
|
||||
const associationCtx = useAssociation() as any;
|
||||
const ctxAssociation = associationCtx?.selectedAssociation ?? null;
|
||||
const { user } = useAuth();
|
||||
|
||||
const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [localAssociationId, setLocalAssociationId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
|
||||
const list = data || [];
|
||||
setAssociations(list);
|
||||
setLocalAssociationId((prev) => prev || ctxAssociation?.id || list[0]?.id || "");
|
||||
});
|
||||
}, [ctxAssociation?.id]);
|
||||
|
||||
const associationId = localAssociationId || ctxAssociation?.id || "";
|
||||
|
||||
const [lots, setLots] = useState<Lot[]>([]);
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [waitlist, setWaitlist] = useState<WaitlistEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// dialogs
|
||||
const [lotDialogOpen, setLotDialogOpen] = useState(false);
|
||||
const [rentalDialogOpen, setRentalDialogOpen] = useState(false);
|
||||
const [waitlistDialogOpen, setWaitlistDialogOpen] = useState(false);
|
||||
const [fulfillDialog, setFulfillDialog] = useState<{ open: boolean; entry: WaitlistEntry | null; lotNumber: string }>({ open: false, entry: null, lotNumber: "" });
|
||||
|
||||
// form state
|
||||
const [lotForm, setLotForm] = useState({ lot_number: "", lot_type: "rv", size: "", monthly_rate: "", notes: "" });
|
||||
const [rentalForm, setRentalForm] = useState({
|
||||
lot_id: "", renter_name: "", renter_email: "", renter_phone: "",
|
||||
vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: ""
|
||||
});
|
||||
const [waitlistForm, setWaitlistForm] = useState({
|
||||
requester_name: "", requester_email: "", requester_phone: "",
|
||||
unit_address: "", requested_lot_type: "either", vehicle_description: "", notes: ""
|
||||
});
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!associationId) return "";
|
||||
return `${window.location.origin}/rv-boat-waitlist/${associationId}`;
|
||||
}, [associationId]);
|
||||
|
||||
const load = async () => {
|
||||
if (!associationId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [lotsRes, rentalsRes, wlRes] = await Promise.all([
|
||||
supabase.from("rv_boat_lots").select("*").eq("association_id", associationId).order("lot_number"),
|
||||
supabase.from("rv_boat_lot_rentals").select("*").eq("association_id", associationId).order("start_date", { ascending: false }),
|
||||
supabase.from("rv_boat_lot_waitlist").select("*").eq("association_id", associationId).order("position"),
|
||||
]);
|
||||
if (lotsRes.error) throw lotsRes.error;
|
||||
if (rentalsRes.error) throw rentalsRes.error;
|
||||
if (wlRes.error) throw wlRes.error;
|
||||
setLots(lotsRes.data || []);
|
||||
setRentals(rentalsRes.data || []);
|
||||
setWaitlist(wlRes.data || []);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [associationId]);
|
||||
|
||||
const createLot = async () => {
|
||||
if (!associationId || !lotForm.lot_number.trim()) { toast.error("Lot number required"); return; }
|
||||
const { error } = await supabase.from("rv_boat_lots").insert({
|
||||
association_id: associationId,
|
||||
lot_number: lotForm.lot_number.trim(),
|
||||
lot_type: lotForm.lot_type,
|
||||
size: lotForm.size || null,
|
||||
monthly_rate: lotForm.monthly_rate ? Number(lotForm.monthly_rate) : null,
|
||||
notes: lotForm.notes || null,
|
||||
});
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Lot added");
|
||||
setLotDialogOpen(false);
|
||||
setLotForm({ lot_number: "", lot_type: "rv", size: "", monthly_rate: "", notes: "" });
|
||||
load();
|
||||
};
|
||||
|
||||
const deleteLot = async (id: string) => {
|
||||
if (!confirm("Delete this lot?")) return;
|
||||
const { error } = await supabase.from("rv_boat_lots").delete().eq("id", id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Lot deleted");
|
||||
load();
|
||||
};
|
||||
|
||||
const createRental = async () => {
|
||||
if (!associationId || !rentalForm.lot_id || !rentalForm.renter_name.trim()) {
|
||||
toast.error("Lot and renter name required"); return;
|
||||
}
|
||||
const { error } = await supabase.from("rv_boat_lot_rentals").insert({
|
||||
association_id: associationId,
|
||||
lot_id: rentalForm.lot_id,
|
||||
renter_name: rentalForm.renter_name.trim(),
|
||||
renter_email: rentalForm.renter_email || null,
|
||||
renter_phone: rentalForm.renter_phone || null,
|
||||
vehicle_description: rentalForm.vehicle_description || null,
|
||||
start_date: rentalForm.start_date,
|
||||
monthly_rate: rentalForm.monthly_rate ? Number(rentalForm.monthly_rate) : null,
|
||||
notes: rentalForm.notes || null,
|
||||
status: "active",
|
||||
});
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Rental created");
|
||||
setRentalDialogOpen(false);
|
||||
setRentalForm({ lot_id: "", renter_name: "", renter_email: "", renter_phone: "", vehicle_description: "", start_date: new Date().toISOString().slice(0, 10), monthly_rate: "", notes: "" });
|
||||
load();
|
||||
};
|
||||
|
||||
const endRental = async (r: Rental) => {
|
||||
if (!confirm("End this rental? The lot will be marked available.")) return;
|
||||
const { error } = await supabase.from("rv_boat_lot_rentals")
|
||||
.update({ status: "ended", end_date: new Date().toISOString().slice(0, 10) })
|
||||
.eq("id", r.id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Rental ended");
|
||||
load();
|
||||
};
|
||||
|
||||
const invitePortal = async (r: Rental, asOwner = false) => {
|
||||
const label = asOwner ? "owner" : "renter";
|
||||
const email = window.prompt(`Email address for the ${label}'s portal account:`, r.renter_email || "");
|
||||
if (!email) return;
|
||||
const trimmed = email.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("invite-rv-renter", {
|
||||
body: { rental_id: r.id, email: trimmed, as_owner: asOwner },
|
||||
});
|
||||
if (error) throw error;
|
||||
if ((data as any)?.error) throw new Error((data as any).error);
|
||||
toast.success((data as any)?.invited ? "Invitation email sent" : "Existing user linked; password reset emailed");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "Failed to invite");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIsOwner = async (r: Rental) => {
|
||||
const next = !r.is_owner;
|
||||
const { error } = await supabase.from("rv_boat_lot_rentals")
|
||||
.update({ is_owner: next })
|
||||
.eq("id", r.id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success(next ? "Marked as owner" : "Marked as renter");
|
||||
load();
|
||||
};
|
||||
|
||||
const addWaitlist = async () => {
|
||||
if (!associationId || !waitlistForm.requester_name.trim()) { toast.error("Name required"); return; }
|
||||
const { error } = await supabase.from("rv_boat_lot_waitlist").insert({
|
||||
association_id: associationId,
|
||||
requester_name: waitlistForm.requester_name.trim(),
|
||||
requester_email: waitlistForm.requester_email || null,
|
||||
requester_phone: waitlistForm.requester_phone || null,
|
||||
unit_address: waitlistForm.unit_address || null,
|
||||
requested_lot_type: waitlistForm.requested_lot_type,
|
||||
vehicle_description: waitlistForm.vehicle_description || null,
|
||||
notes: waitlistForm.notes || null,
|
||||
source: "manual",
|
||||
status: "waiting",
|
||||
position: 0,
|
||||
});
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Added to waitlist");
|
||||
setWaitlistDialogOpen(false);
|
||||
setWaitlistForm({ requester_name: "", requester_email: "", requester_phone: "", unit_address: "", requested_lot_type: "either", vehicle_description: "", notes: "" });
|
||||
load();
|
||||
};
|
||||
|
||||
const movePosition = async (entry: WaitlistEntry, direction: "up" | "down") => {
|
||||
const waiting = waitlist.filter(w => w.status === "waiting").sort((a, b) => a.position - b.position);
|
||||
const idx = waiting.findIndex(w => w.id === entry.id);
|
||||
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= waiting.length) return;
|
||||
const other = waiting[swapIdx];
|
||||
// Use temporary position to avoid unique-ish collisions if any (none enforced, but safe)
|
||||
const tempPos = -Math.floor(Math.random() * 100000) - 1;
|
||||
await supabase.from("rv_boat_lot_waitlist").update({ position: tempPos }).eq("id", entry.id);
|
||||
await supabase.from("rv_boat_lot_waitlist").update({ position: entry.position }).eq("id", other.id);
|
||||
await supabase.from("rv_boat_lot_waitlist").update({ position: other.position }).eq("id", entry.id);
|
||||
load();
|
||||
};
|
||||
|
||||
const removeWaitlist = async (id: string) => {
|
||||
if (!confirm("Remove this entry from the waitlist?")) return;
|
||||
const { error } = await supabase.from("rv_boat_lot_waitlist").update({ status: "removed" }).eq("id", id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Removed");
|
||||
load();
|
||||
};
|
||||
|
||||
const openFulfill = (entry: WaitlistEntry) => {
|
||||
setFulfillDialog({ open: true, entry, lotNumber: "" });
|
||||
};
|
||||
|
||||
const confirmFulfill = async () => {
|
||||
const entry = fulfillDialog.entry;
|
||||
if (!entry) return;
|
||||
if (!fulfillDialog.lotNumber.trim()) { toast.error("Enter the lot number assigned"); return; }
|
||||
const { error } = await supabase.from("rv_boat_lot_waitlist").update({
|
||||
status: "fulfilled",
|
||||
fulfilled_at: new Date().toISOString(),
|
||||
fulfilled_lot_number: fulfillDialog.lotNumber.trim(),
|
||||
fulfilled_by: user?.id || null,
|
||||
}).eq("id", entry.id);
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success("Marked fulfilled");
|
||||
setFulfillDialog({ open: false, entry: null, lotNumber: "" });
|
||||
load();
|
||||
};
|
||||
|
||||
const copyPublicLink = async () => {
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
toast.success("Public waitlist link copied");
|
||||
};
|
||||
|
||||
const waitingEntries = waitlist.filter(w => w.status === "waiting").sort((a, b) => a.position - b.position);
|
||||
const fulfilledEntries = waitlist.filter(w => w.status === "fulfilled" || w.status === "removed");
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">RV / Boat Lot Rentals</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage lot inventory, active rentals, and the waitlist.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={associationId} onValueChange={setLocalAssociationId}>
|
||||
<SelectTrigger className="w-[260px]"><SelectValue placeholder="Select association" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={copyPublicLink} disabled={!associationId}>
|
||||
<Copy className="h-4 w-4 mr-2" /> Copy public waitlist link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!associationId ? (
|
||||
<p className="text-muted-foreground">Select an association to manage RV/Boat lots.</p>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="waitlist" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="waitlist">Waitlist ({waitingEntries.length})</TabsTrigger>
|
||||
<TabsTrigger value="lots">Lots ({lots.length})</TabsTrigger>
|
||||
<TabsTrigger value="rentals">Active Rentals ({rentals.filter(r => r.status === "active").length})</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Waitlist */}
|
||||
<TabsContent value="waitlist" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Current Waitlist</CardTitle>
|
||||
<Dialog open={waitlistDialogOpen} onOpenChange={setWaitlistDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Add to waitlist</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add waitlist entry</DialogTitle>
|
||||
<DialogDescription>New entries are added to the bottom of the list.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>Name *</Label><Input value={waitlistForm.requester_name} onChange={e => setWaitlistForm({ ...waitlistForm, requester_name: e.target.value })} /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Email</Label><Input type="email" value={waitlistForm.requester_email} onChange={e => setWaitlistForm({ ...waitlistForm, requester_email: e.target.value })} /></div>
|
||||
<div><Label>Phone</Label><Input value={waitlistForm.requester_phone} onChange={e => setWaitlistForm({ ...waitlistForm, requester_phone: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><Label>Unit / Address</Label><Input value={waitlistForm.unit_address} onChange={e => setWaitlistForm({ ...waitlistForm, unit_address: e.target.value })} /></div>
|
||||
<div>
|
||||
<Label>Lot type</Label>
|
||||
<Select value={waitlistForm.requested_lot_type} onValueChange={v => setWaitlistForm({ ...waitlistForm, requested_lot_type: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rv">RV</SelectItem>
|
||||
<SelectItem value="boat">Boat</SelectItem>
|
||||
<SelectItem value="either">Either</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Vehicle description</Label><Input value={waitlistForm.vehicle_description} onChange={e => setWaitlistForm({ ...waitlistForm, vehicle_description: e.target.value })} /></div>
|
||||
<div><Label>Notes</Label><Textarea value={waitlistForm.notes} onChange={e => setWaitlistForm({ ...waitlistForm, notes: e.target.value })} /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWaitlistDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={addWaitlist}>Add</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? <p className="text-sm text-muted-foreground">Loading…</p> :
|
||||
waitingEntries.length === 0 ? <p className="text-sm text-muted-foreground">No one on the waitlist.</p> : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">#</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{waitingEntries.map((w, i) => (
|
||||
<TableRow key={w.id}>
|
||||
<TableCell className="font-semibold">{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{w.requester_name}</div>
|
||||
{w.unit_address && <div className="text-xs text-muted-foreground">{w.unit_address}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{w.requester_email && <div>{w.requester_email}</div>}
|
||||
{w.requester_phone && <div>{w.requester_phone}</div>}
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="outline">{w.requested_lot_type}</Badge></TableCell>
|
||||
<TableCell className="text-xs">{new Date(w.joined_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{w.source === "public_form" ? "Public" : "Manual"}</Badge></TableCell>
|
||||
<TableCell className="text-right space-x-1">
|
||||
<Button size="icon" variant="ghost" onClick={() => movePosition(w, "up")} disabled={i === 0}><ArrowUp className="h-4 w-4" /></Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => movePosition(w, "down")} disabled={i === waitingEntries.length - 1}><ArrowDown className="h-4 w-4" /></Button>
|
||||
<Button size="sm" variant="default" onClick={() => openFulfill(w)}><Check className="h-4 w-4 mr-1" /> Check off</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => removeWaitlist(w.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Lots */}
|
||||
<TabsContent value="lots" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Lot Inventory</CardTitle>
|
||||
<Dialog open={lotDialogOpen} onOpenChange={setLotDialogOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-1" /> Add lot</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add lot</DialogTitle>
|
||||
<DialogDescription>Add a new RV or boat lot to inventory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div><Label>Lot number *</Label><Input value={lotForm.lot_number} onChange={e => setLotForm({ ...lotForm, lot_number: e.target.value })} /></div>
|
||||
<div>
|
||||
<Label>Type</Label>
|
||||
<Select value={lotForm.lot_type} onValueChange={v => setLotForm({ ...lotForm, lot_type: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rv">RV</SelectItem>
|
||||
<SelectItem value="boat">Boat</SelectItem>
|
||||
<SelectItem value="either">Either</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Size</Label><Input placeholder="10x30" value={lotForm.size} onChange={e => setLotForm({ ...lotForm, size: e.target.value })} /></div>
|
||||
<div><Label>Monthly rate</Label><Input type="number" value={lotForm.monthly_rate} onChange={e => setLotForm({ ...lotForm, monthly_rate: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><Label>Notes</Label><Textarea value={lotForm.notes} onChange={e => setLotForm({ ...lotForm, notes: e.target.value })} /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setLotDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={createLot}>Add</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lots.length === 0 ? <p className="text-sm text-muted-foreground">No lots yet.</p> : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Lot #</TableHead><TableHead>Type</TableHead><TableHead>Size</TableHead>
|
||||
<TableHead>Rate</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{lots.map(lot => (
|
||||
<TableRow key={lot.id}>
|
||||
<TableCell className="font-medium">{lot.lot_number}</TableCell>
|
||||
<TableCell><Badge variant="outline">{lot.lot_type}</Badge></TableCell>
|
||||
<TableCell>{lot.size || "-"}</TableCell>
|
||||
<TableCell>{lot.monthly_rate ? `$${lot.monthly_rate}` : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={lot.status === "available" ? "secondary" : lot.status === "occupied" ? "default" : "outline"}>{lot.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="icon" variant="ghost" onClick={() => deleteLot(lot.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Rentals */}
|
||||
<TabsContent value="rentals" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Active Rentals</CardTitle>
|
||||
<Dialog open={rentalDialogOpen} onOpenChange={setRentalDialogOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4 mr-1" /> New rental</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create rental</DialogTitle>
|
||||
<DialogDescription>Assign a lot to a renter.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Lot *</Label>
|
||||
<Select value={rentalForm.lot_id} onValueChange={v => setRentalForm({ ...rentalForm, lot_id: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="Choose lot" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{lots.filter(l => l.status === "available").map(l => (
|
||||
<SelectItem key={l.id} value={l.id}>Lot {l.lot_number} ({l.lot_type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>Renter name *</Label><Input value={rentalForm.renter_name} onChange={e => setRentalForm({ ...rentalForm, renter_name: e.target.value })} /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Email</Label><Input type="email" value={rentalForm.renter_email} onChange={e => setRentalForm({ ...rentalForm, renter_email: e.target.value })} /></div>
|
||||
<div><Label>Phone</Label><Input value={rentalForm.renter_phone} onChange={e => setRentalForm({ ...rentalForm, renter_phone: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><Label>Vehicle description</Label><Input value={rentalForm.vehicle_description} onChange={e => setRentalForm({ ...rentalForm, vehicle_description: e.target.value })} /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Start date</Label><Input type="date" value={rentalForm.start_date} onChange={e => setRentalForm({ ...rentalForm, start_date: e.target.value })} /></div>
|
||||
<div><Label>Monthly rate</Label><Input type="number" value={rentalForm.monthly_rate} onChange={e => setRentalForm({ ...rentalForm, monthly_rate: e.target.value })} /></div>
|
||||
</div>
|
||||
<div><Label>Notes</Label><Textarea value={rentalForm.notes} onChange={e => setRentalForm({ ...rentalForm, notes: e.target.value })} /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRentalDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={createRental}>Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rentals.filter(r => r.status === "active").length === 0 ? <p className="text-sm text-muted-foreground">No active rentals.</p> : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Lot</TableHead><TableHead>Renter</TableHead><TableHead>Vehicle</TableHead>
|
||||
<TableHead>Start</TableHead><TableHead>Rate</TableHead><TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{rentals.filter(r => r.status === "active").map(r => {
|
||||
const lot = lots.find(l => l.id === r.lot_id);
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{lot?.lot_number || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{r.renter_name}</span>
|
||||
<Badge variant={r.is_owner ? "default" : "secondary"} className="text-[10px]">
|
||||
{r.is_owner ? "Owner" : "Renter"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{r.renter_email}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{r.vehicle_description || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{r.start_date}</TableCell>
|
||||
<TableCell>{r.monthly_rate ? `$${r.monthly_rate}` : "-"}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => toggleIsOwner(r)}>
|
||||
{r.is_owner ? "Mark as Renter" : "Mark as Owner"}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, false)}>Invite Renter</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, true)}>Invite Owner</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => endRental(r)}>End rental</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* History */}
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Waitlist History</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{fulfilledEntries.length === 0 ? <p className="text-sm text-muted-foreground">No history yet.</p> : (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Name</TableHead><TableHead>Status</TableHead>
|
||||
<TableHead>Lot Assigned</TableHead><TableHead>Date</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{fulfilledEntries.map(w => (
|
||||
<TableRow key={w.id}>
|
||||
<TableCell>{w.requester_name}</TableCell>
|
||||
<TableCell><Badge variant={w.status === "fulfilled" ? "default" : "outline"}>{w.status}</Badge></TableCell>
|
||||
<TableCell>{w.fulfilled_lot_number || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{w.fulfilled_at ? new Date(w.fulfilled_at).toLocaleDateString() : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Fulfill dialog */}
|
||||
<Dialog open={fulfillDialog.open} onOpenChange={o => setFulfillDialog({ ...fulfillDialog, open: o })}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Check off waitlist entry</DialogTitle>
|
||||
<DialogDescription>Enter the lot number assigned to {fulfillDialog.entry?.requester_name}.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Lot number assigned *</Label>
|
||||
<Input value={fulfillDialog.lotNumber} onChange={e => setFulfillDialog({ ...fulfillDialog, lotNumber: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFulfillDialog({ open: false, entry: null, lotNumber: "" })}>Cancel</Button>
|
||||
<Button onClick={confirmFulfill}>Mark fulfilled</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user