Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions client/src/components/property/EditLeaseDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { Pencil } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { useUpdateLease, type Lease } from '@/hooks/use-property';

interface EditLeaseDialogProps {
propertyId: string;
lease: Lease;
open: boolean;
onOpenChange: (open: boolean) => void;
}

export default function EditLeaseDialog({ propertyId, lease, open, onOpenChange }: EditLeaseDialogProps) {
const [form, setForm] = useState({
tenantName: '',
tenantEmail: '',
tenantPhone: '',
startDate: '',
endDate: '',
monthlyRent: '',
securityDeposit: '',
status: 'active',
});
const { toast } = useToast();
const updateLease = useUpdateLease(propertyId, lease.id);

useEffect(() => {
if (open) {
setForm({
tenantName: lease.tenantName || '',
tenantEmail: lease.tenantEmail || '',
tenantPhone: lease.tenantPhone || '',
startDate: lease.startDate?.slice(0, 10) || '',
endDate: lease.endDate?.slice(0, 10) || '',
monthlyRent: lease.monthlyRent || '',
securityDeposit: lease.securityDeposit || '',
status: lease.status || 'active',
});
}
}, [open, lease]);

function handleChange(field: keyof typeof form, value: string) {
setForm(prev => ({ ...prev, [field]: value }));
}

function handleSubmit(e: React.FormEvent) {
e.preventDefault();

const payload: Record<string, string | undefined> = {
tenantName: form.tenantName.trim(),
startDate: form.startDate,
endDate: form.endDate,
monthlyRent: form.monthlyRent,
status: form.status,
};
if (form.tenantEmail.trim()) payload.tenantEmail = form.tenantEmail.trim();
if (form.tenantPhone.trim()) payload.tenantPhone = form.tenantPhone.trim();
if (form.securityDeposit) payload.securityDeposit = form.securityDeposit;

updateLease.mutate(payload, {
onSuccess: () => {
toast({ title: 'Lease updated', description: `Lease for ${payload.tenantName} has been saved.` });
onOpenChange(false);
},
onError: (error: Error) => {
toast({ title: 'Failed to update lease', description: error.message, variant: 'destructive' });
},
});
}

const isValid =
form.tenantName.trim() !== '' &&
form.startDate !== '' &&
form.endDate !== '' &&
form.monthlyRent !== '';

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-5 w-5" />
Edit Lease
</DialogTitle>
<DialogDescription>Update lease terms and tenant information.</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-lease-tenant-name">Tenant Name *</Label>
<Input id="edit-lease-tenant-name" placeholder="e.g. John Smith" value={form.tenantName}
onChange={e => handleChange('tenantName', e.target.value)} required />
</div>

<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="edit-lease-tenant-email">Email</Label>
<Input id="edit-lease-tenant-email" type="email" placeholder="tenant@example.com"
value={form.tenantEmail} onChange={e => handleChange('tenantEmail', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-lease-tenant-phone">Phone</Label>
<Input id="edit-lease-tenant-phone" type="tel" placeholder="(312) 555-0100"
value={form.tenantPhone} onChange={e => handleChange('tenantPhone', e.target.value)} />
</div>
</div>

<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="edit-lease-start">Start Date *</Label>
<Input id="edit-lease-start" type="date" value={form.startDate}
onChange={e => handleChange('startDate', e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="edit-lease-end">End Date *</Label>
<Input id="edit-lease-end" type="date" value={form.endDate}
onChange={e => handleChange('endDate', e.target.value)} required />
</div>
</div>

<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="edit-lease-rent">Monthly Rent *</Label>
<Input id="edit-lease-rent" type="number" min="0" step="0.01" placeholder="0.00"
value={form.monthlyRent} onChange={e => handleChange('monthlyRent', e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="edit-lease-deposit">Security Deposit</Label>
<Input id="edit-lease-deposit" type="number" min="0" step="0.01" placeholder="0.00"
value={form.securityDeposit} onChange={e => handleChange('securityDeposit', e.target.value)} />
</div>
</div>

<div className="space-y-2">
<Label htmlFor="edit-lease-status">Status</Label>
<Select value={form.status} onValueChange={v => handleChange('status', v)}>
<SelectTrigger id="edit-lease-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
<SelectItem value="terminated">Terminated</SelectItem>
</SelectContent>
</Select>
</div>

<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}
disabled={updateLease.isPending}>Cancel</Button>
<Button type="submit" disabled={!isValid || updateLease.isPending}>
{updateLease.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
125 changes: 125 additions & 0 deletions client/src/components/property/EditUnitDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { Pencil } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { useUpdateUnit, type Unit } from '@/hooks/use-property';

interface EditUnitDialogProps {
propertyId: string;
unit: Unit;
open: boolean;
onOpenChange: (open: boolean) => void;
}

export default function EditUnitDialog({ propertyId, unit, open, onOpenChange }: EditUnitDialogProps) {
const [form, setForm] = useState({
unitNumber: '',
bedrooms: '',
bathrooms: '',
squareFeet: '',
monthlyRent: '',
});
const { toast } = useToast();
const updateUnit = useUpdateUnit(propertyId, unit.id);

useEffect(() => {
if (open) {
setForm({
unitNumber: unit.unitNumber,
bedrooms: String(unit.bedrooms ?? ''),
bathrooms: String(unit.bathrooms ?? ''),
squareFeet: String(unit.squareFeet ?? ''),
monthlyRent: unit.monthlyRent || '',
});
}
}, [open, unit]);

function handleChange(field: keyof typeof form, value: string) {
setForm(prev => ({ ...prev, [field]: value }));
}

function handleSubmit(e: React.FormEvent) {
e.preventDefault();

const payload: Record<string, string | number | undefined> = {
unitNumber: form.unitNumber.trim(),
};
if (form.bedrooms) payload.bedrooms = parseInt(form.bedrooms, 10);
if (form.bathrooms) payload.bathrooms = form.bathrooms;
if (form.squareFeet) payload.squareFeet = parseInt(form.squareFeet, 10);
if (form.monthlyRent) payload.monthlyRent = form.monthlyRent;

updateUnit.mutate(payload, {
onSuccess: () => {
toast({ title: 'Unit updated', description: `Unit ${payload.unitNumber} has been saved.` });
onOpenChange(false);
},
onError: (error: Error) => {
toast({ title: 'Failed to update unit', description: error.message, variant: 'destructive' });
},
});
}

const isValid = form.unitNumber.trim() !== '';

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-5 w-5" />
Edit Unit
</DialogTitle>
<DialogDescription>Update unit details.</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-unit-number">Unit Number *</Label>
<Input id="edit-unit-number" placeholder="e.g. 1A, 201, Studio" value={form.unitNumber}
onChange={e => handleChange('unitNumber', e.target.value)} required />
</div>

<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="edit-unit-beds">Bedrooms</Label>
<Input id="edit-unit-beds" type="number" min="0" placeholder="0" value={form.bedrooms}
onChange={e => handleChange('bedrooms', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-unit-baths">Bathrooms</Label>
<Input id="edit-unit-baths" type="number" min="0" step="0.5" placeholder="0" value={form.bathrooms}
onChange={e => handleChange('bathrooms', e.target.value)} />
</div>
</div>

<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="edit-unit-sqft">Square Feet</Label>
<Input id="edit-unit-sqft" type="number" min="0" placeholder="0" value={form.squareFeet}
onChange={e => handleChange('squareFeet', e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-unit-rent">Monthly Rent</Label>
<Input id="edit-unit-rent" type="number" min="0" step="0.01" placeholder="0.00" value={form.monthlyRent}
onChange={e => handleChange('monthlyRent', e.target.value)} />
</div>
</div>

<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}
disabled={updateUnit.isPending}>Cancel</Button>
<Button type="submit" disabled={!isValid || updateUnit.isPending}>
{updateUnit.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
26 changes: 26 additions & 0 deletions client/src/hooks/use-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,32 @@ export function useCreateLease(propertyId: string) {
});
}

export function useUpdateUnit(propertyId: string, unitId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Unit>) =>
apiRequest('PATCH', `/api/properties/${propertyId}/units/${unitId}`, data).then(r => r.json()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [`/api/properties/${propertyId}/units`] });
qc.invalidateQueries({ queryKey: [`/api/properties/${propertyId}/financials`] });
qc.invalidateQueries({ queryKey: [`/api/properties/${propertyId}/rent-roll`] });
},
});
}

export function useUpdateLease(propertyId: string, leaseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<Lease>) =>
apiRequest('PATCH', `/api/properties/${propertyId}/leases/${leaseId}`, data).then(r => r.json()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [`/api/properties/${propertyId}/leases`] });
qc.invalidateQueries({ queryKey: [`/api/properties/${propertyId}/rent-roll`] });
qc.invalidateQueries({ queryKey: [`/api/properties/${propertyId}/financials`] });
},
});
}

export function useSendPropertyAdvice(propertyId: string) {
return useMutation<AIAdviceResponse, Error, string>({
mutationFn: (message: string) =>
Expand Down
27 changes: 26 additions & 1 deletion client/src/pages/PropertyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import ValuationTab from '@/components/property/ValuationTab';
import AddUnitDialog from '@/components/property/AddUnitDialog';
import AddLeaseDialog from '@/components/property/AddLeaseDialog';
import EditPropertyDialog from '@/components/property/EditPropertyDialog';
import EditUnitDialog from '@/components/property/EditUnitDialog';
import EditLeaseDialog from '@/components/property/EditLeaseDialog';
import type { Unit, Lease } from '@/hooks/use-property';
import WorkspaceTab from '@/components/property/WorkspaceTab';
import CommsPanel from '@/components/property/CommsPanel';
import WorkflowBoard from '@/components/property/WorkflowBoard';
Expand All @@ -30,6 +33,10 @@ export default function PropertyDetail() {
const [addUnitOpen, setAddUnitOpen] = useState(false);
const [addLeaseOpen, setAddLeaseOpen] = useState(false);
const [editPropertyOpen, setEditPropertyOpen] = useState(false);
const [editUnitOpen, setEditUnitOpen] = useState(false);
const [selectedUnit, setSelectedUnit] = useState<Unit | null>(null);
const [editLeaseOpen, setEditLeaseOpen] = useState(false);
const [selectedLease, setSelectedLease] = useState<Lease | null>(null);

const { data: property, isLoading } = useProperty(id);
const { data: units = [] } = usePropertyUnits(id);
Expand Down Expand Up @@ -90,6 +97,8 @@ export default function PropertyDetail() {
{id && <AddUnitDialog propertyId={id} open={addUnitOpen} onOpenChange={setAddUnitOpen} />}
{id && <AddLeaseDialog propertyId={id} open={addLeaseOpen} onOpenChange={setAddLeaseOpen} />}
{property && <EditPropertyDialog property={property} open={editPropertyOpen} onOpenChange={setEditPropertyOpen} />}
{selectedUnit && id && <EditUnitDialog propertyId={id} unit={selectedUnit} open={editUnitOpen} onOpenChange={setEditUnitOpen} />}
{selectedLease && id && <EditLeaseDialog propertyId={id} lease={selectedLease} open={editLeaseOpen} onOpenChange={setEditLeaseOpen} />}

<Tabs defaultValue="overview">
<TabsList className="flex-wrap">
Expand Down Expand Up @@ -165,6 +174,7 @@ export default function PropertyDetail() {
<TableHead className="text-right">Rent</TableHead>
<TableHead>Tenant</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[70px]"></TableHead>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new actions column header is empty. For accessibility, add a label (can be visually hidden with sr-only) so screen readers can announce the column purpose (e.g. "Actions").

Suggested change
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]">
<span className="sr-only">Actions</span>
</TableHead>

Copilot uses AI. Check for mistakes.
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -176,12 +186,27 @@ export default function PropertyDetail() {
<TableCell>{u.bedrooms}br / {u.bathrooms}ba</TableCell>
<TableCell className="text-right">{u.squareFeet?.toLocaleString()}</TableCell>
<TableCell className="text-right">{formatCurrency(parseFloat(u.monthlyRent || '0'))}</TableCell>
<TableCell>{lease?.tenantName || '\u2014'}</TableCell>
<TableCell>
{lease ? (
<button
className="text-left hover:underline"
onClick={() => { setSelectedLease(lease); setEditLeaseOpen(true); }}
>
{lease.tenantName}
</button>
) : '\u2014'}
</TableCell>
<TableCell>
<Badge variant={lease ? 'default' : 'secondary'}>
{lease ? 'Leased' : 'Vacant'}
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7"
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Icon-only edit button (Pencil) has no accessible name. Add an aria-label (e.g. "Edit unit") or include a visually-hidden <span className="sr-only">Edit unit</span> inside the button so screen readers can identify the action.

Suggested change
<Button variant="ghost" size="icon" className="h-7 w-7"
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Edit unit"

Copilot uses AI. Check for mistakes.
onClick={() => { setSelectedUnit(u); setEditUnitOpen(true); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
);
})}
Expand Down
Loading