From 03942d0ee4406a9a0417838d7cfca9acd01883f9 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:21:19 -0500 Subject: [PATCH 1/5] feat: core bookkeeping UI + property management enhancements Add four new finance pages consuming existing backend APIs: - Transactions: full table with filters, search, sort, add dialog, CSV export, reconcile toggle, pagination (50/page) - Accounts: cards grouped by type, balance summary (assets/liabilities/ net worth), detail drill-down with account transactions, add dialog - Reports: consolidated P&L, by-entity and by-state breakdowns, quality metrics, tax readiness checklist, tax automation trigger - Integrations: sync status cards, Wave sync, TurboTenant CSV import, recurring charges with optimization recommendations Backend additions: - POST /api/transactions (create) - PATCH /api/transactions/:id (update/reconcile) - POST /api/accounts (general account creation) - SystemStorage.updateTransaction() method Property management (prior session): - AddUnit, AddLease, EditProperty dialogs - CommsPanel (SMS/email via Twilio/SendGrid) - WorkflowBoard (maintenance/expense approvals) - WorkspaceTab with Google Workspace embeds - Google OAuth, comms, and workflow routes - comms_log and workflows schema tables Navigation: 4 new sidebar items with role-based visibility (Transactions/Accounts = bookkeeper+, Reports/Integrations = accountant+) Verified: tsc --noEmit clean, 135/135 tests passing Co-Authored-By: Claude Opus 4.6 --- client/src/App.tsx | 8 + client/src/components/layout/Sidebar.tsx | 7 +- .../components/property/AddLeaseDialog.tsx | 170 ++++++++ .../src/components/property/AddUnitDialog.tsx | 123 ++++++ client/src/components/property/CommsPanel.tsx | 233 ++++++++++ .../property/EditPropertyDialog.tsx | 180 ++++++++ .../property/GoogleCalendarEmbed.tsx | 141 ++++++ .../components/property/GoogleDriveEmbed.tsx | 46 ++ .../components/property/GoogleSheetsEmbed.tsx | 46 ++ .../property/PropertyDetailPanel.tsx | 23 +- .../src/components/property/WorkflowBoard.tsx | 250 +++++++++++ .../src/components/property/WorkspaceTab.tsx | 61 +++ client/src/hooks/use-accounts.ts | 77 ++++ client/src/hooks/use-integrations.ts | 94 ++++ client/src/hooks/use-reports.ts | 106 +++++ client/src/hooks/use-transactions.ts | 63 +++ client/src/pages/Accounts.tsx | 311 ++++++++++++++ client/src/pages/Connections.tsx | 42 +- client/src/pages/Integrations.tsx | 247 +++++++++++ client/src/pages/PropertyDetail.tsx | 49 ++- client/src/pages/Reports.tsx | 302 +++++++++++++ client/src/pages/Transactions.tsx | 400 ++++++++++++++++++ database/system.schema.ts | 47 ++ server/app.ts | 12 +- server/env.ts | 11 + server/lib/google-api.ts | 160 +++++++ server/lib/sendgrid.ts | 111 +++++ server/lib/twilio.ts | 77 ++++ server/routes/accounts.ts | 37 ++ server/routes/comms.ts | 159 +++++++ server/routes/google.ts | 167 ++++++++ server/routes/transactions.ts | 77 +++- server/routes/workflows.ts | 78 ++++ server/storage/system.ts | 65 +++ 34 files changed, 3960 insertions(+), 20 deletions(-) create mode 100644 client/src/components/property/AddLeaseDialog.tsx create mode 100644 client/src/components/property/AddUnitDialog.tsx create mode 100644 client/src/components/property/CommsPanel.tsx create mode 100644 client/src/components/property/EditPropertyDialog.tsx create mode 100644 client/src/components/property/GoogleCalendarEmbed.tsx create mode 100644 client/src/components/property/GoogleDriveEmbed.tsx create mode 100644 client/src/components/property/GoogleSheetsEmbed.tsx create mode 100644 client/src/components/property/WorkflowBoard.tsx create mode 100644 client/src/components/property/WorkspaceTab.tsx create mode 100644 client/src/hooks/use-accounts.ts create mode 100644 client/src/hooks/use-integrations.ts create mode 100644 client/src/hooks/use-reports.ts create mode 100644 client/src/hooks/use-transactions.ts create mode 100644 client/src/pages/Accounts.tsx create mode 100644 client/src/pages/Integrations.tsx create mode 100644 client/src/pages/Reports.tsx create mode 100644 client/src/pages/Transactions.tsx create mode 100644 server/lib/google-api.ts create mode 100644 server/lib/sendgrid.ts create mode 100644 server/lib/twilio.ts create mode 100644 server/routes/comms.ts create mode 100644 server/routes/google.ts create mode 100644 server/routes/workflows.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 1883332..ff1f5e5 100755 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,10 @@ import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; import Connections from "@/pages/Connections"; import Dashboard from "@/pages/Dashboard"; +import Transactions from "@/pages/Transactions"; +import Accounts from "@/pages/Accounts"; +import Reports from "@/pages/Reports"; +import Integrations from "@/pages/Integrations"; import { User } from "@shared/schema"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { TenantProvider } from "@/contexts/TenantContext"; @@ -33,6 +37,10 @@ function Router() { + + + + diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index 76d0a26..624a839 100755 --- a/client/src/components/layout/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -3,7 +3,8 @@ import { cn } from "@/lib/utils"; import { Settings, Plug, Building2, Shield, ChevronDown, ChevronRight, ChevronsUpDown, - Menu, X, Activity, LayoutDashboard + Menu, X, Activity, LayoutDashboard, + ArrowLeftRight, Wallet, BarChart3, Cable } from "lucide-react"; import { useState, useMemo } from "react"; import { useRole, type UserRole } from "@/contexts/RoleContext"; @@ -21,6 +22,10 @@ interface NavItem { const NAV_ITEMS: NavItem[] = [ { href: "/", label: "Portfolio", icon: Building2, roles: ["cfo", "accountant", "bookkeeper", "user"] }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, roles: ["cfo", "accountant", "bookkeeper", "user"] }, + { href: "/transactions", label: "Transactions", icon: ArrowLeftRight, roles: ["cfo", "accountant", "bookkeeper"] }, + { href: "/accounts", label: "Accounts", icon: Wallet, roles: ["cfo", "accountant", "bookkeeper"] }, + { href: "/reports", label: "Reports", icon: BarChart3, roles: ["cfo", "accountant"] }, + { href: "/integrations", label: "Integrations", icon: Cable, roles: ["cfo", "accountant"] }, { href: "/connections", label: "Connections", icon: Plug, roles: ["cfo", "accountant"] }, { href: "/admin", label: "Admin", icon: Shield, roles: ["cfo"] }, { href: "/settings", label: "Settings", icon: Settings, roles: ["cfo", "accountant", "bookkeeper", "user"] }, diff --git a/client/src/components/property/AddLeaseDialog.tsx b/client/src/components/property/AddLeaseDialog.tsx new file mode 100644 index 0000000..5cd6bc8 --- /dev/null +++ b/client/src/components/property/AddLeaseDialog.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { FileText } 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 { useCreateLease, usePropertyUnits } from '@/hooks/use-property'; + +interface AddLeaseDialogProps { + propertyId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const initialFormState = { + unitId: '', + tenantName: '', + tenantEmail: '', + tenantPhone: '', + startDate: '', + endDate: '', + monthlyRent: '', + securityDeposit: '', +}; + +export default function AddLeaseDialog({ propertyId, open, onOpenChange }: AddLeaseDialogProps) { + const [form, setForm] = useState(initialFormState); + const { toast } = useToast(); + const createLease = useCreateLease(propertyId); + const { data: units = [] } = usePropertyUnits(propertyId); + + function resetForm() { + setForm(initialFormState); + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) resetForm(); + onOpenChange(nextOpen); + } + + function handleChange(field: keyof typeof form, value: string) { + setForm(prev => ({ ...prev, [field]: value })); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const payload: Record = { + unitId: form.unitId, + tenantName: form.tenantName.trim(), + startDate: form.startDate, + endDate: form.endDate, + monthlyRent: form.monthlyRent, + }; + 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; + + createLease.mutate(payload, { + onSuccess: () => { + toast({ title: 'Lease created', description: `Lease for ${payload.tenantName} has been created.` }); + handleOpenChange(false); + }, + onError: (error: Error) => { + toast({ title: 'Failed to create lease', description: error.message, variant: 'destructive' }); + }, + }); + } + + const isValid = + form.unitId !== '' && + form.tenantName.trim() !== '' && + form.startDate !== '' && + form.endDate !== '' && + form.monthlyRent !== ''; + + return ( + + + + + + Add Lease + + Create a new lease for a unit in this property. + + +
+
+ + + {units.length === 0 && ( +

No units available. Add a unit first.

+ )} +
+ +
+ + handleChange('tenantName', e.target.value)} required /> +
+ +
+
+ + handleChange('tenantEmail', e.target.value)} /> +
+
+ + handleChange('tenantPhone', e.target.value)} /> +
+
+ +
+
+ + handleChange('startDate', e.target.value)} required /> +
+
+ + handleChange('endDate', e.target.value)} required /> +
+
+ +
+
+ + handleChange('monthlyRent', e.target.value)} required /> +
+
+ + handleChange('securityDeposit', e.target.value)} /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/client/src/components/property/AddUnitDialog.tsx b/client/src/components/property/AddUnitDialog.tsx new file mode 100644 index 0000000..b6af38a --- /dev/null +++ b/client/src/components/property/AddUnitDialog.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { Home } 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 { useCreateUnit } from '@/hooks/use-property'; + +interface AddUnitDialogProps { + propertyId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const initialFormState = { + unitNumber: '', + bedrooms: '', + bathrooms: '', + squareFeet: '', + monthlyRent: '', +}; + +export default function AddUnitDialog({ propertyId, open, onOpenChange }: AddUnitDialogProps) { + const [form, setForm] = useState(initialFormState); + const { toast } = useToast(); + const createUnit = useCreateUnit(propertyId); + + function resetForm() { + setForm(initialFormState); + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) resetForm(); + onOpenChange(nextOpen); + } + + function handleChange(field: keyof typeof form, value: string) { + setForm(prev => ({ ...prev, [field]: value })); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const payload: Record = { + 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; + + createUnit.mutate(payload, { + onSuccess: () => { + toast({ title: 'Unit added', description: `Unit ${payload.unitNumber} has been created.` }); + handleOpenChange(false); + }, + onError: (error: Error) => { + toast({ title: 'Failed to add unit', description: error.message, variant: 'destructive' }); + }, + }); + } + + const isValid = form.unitNumber.trim() !== ''; + + return ( + + + + + + Add Unit + + Add a new rental unit to this property. + + +
+
+ + handleChange('unitNumber', e.target.value)} required /> +
+ +
+
+ + handleChange('bedrooms', e.target.value)} /> +
+
+ + handleChange('bathrooms', e.target.value)} /> +
+
+ +
+
+ + handleChange('squareFeet', e.target.value)} /> +
+
+ + handleChange('monthlyRent', e.target.value)} /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/client/src/components/property/CommsPanel.tsx b/client/src/components/property/CommsPanel.tsx new file mode 100644 index 0000000..0848b6d --- /dev/null +++ b/client/src/components/property/CommsPanel.tsx @@ -0,0 +1,233 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from '@/components/ui/select'; +import { MessageSquare, Send, Mail, Phone } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { apiRequest } from '@/lib/queryClient'; +import type { Lease } from '@/hooks/use-property'; + +interface CommsPanelProps { + propertyId: string; + leases: Lease[]; +} + +interface CommsLogEntry { + id: string; + recipientName: string; + recipientContact: string; + channel: 'sms' | 'email'; + template: string | null; + body: string; + status: string; + sentAt: string; +} + +interface CommsStatus { + twilio: boolean; + sendgrid: boolean; +} + +const TEMPLATES = [ + { value: 'custom', label: 'Custom Message' }, + { value: 'lease_reminder_30', label: 'Lease Reminder (30 days)' }, + { value: 'lease_reminder_60', label: 'Lease Reminder (60 days)' }, + { value: 'lease_reminder_90', label: 'Lease Reminder (90 days)' }, + { value: 'maintenance_scheduled', label: 'Maintenance Scheduled' }, + { value: 'rent_receipt', label: 'Rent Receipt' }, + { value: 'questionnaire', label: 'Follow-up Questionnaire' }, +] as const; + +export default function CommsPanel({ propertyId, leases }: CommsPanelProps) { + const [channel, setChannel] = useState<'sms' | 'email'>('sms'); + const [template, setTemplate] = useState('custom'); + const [recipient, setRecipient] = useState(''); + const [recipientName, setRecipientName] = useState(''); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const { data: status } = useQuery({ + queryKey: ['/api/comms/status'], + staleTime: 5 * 60 * 1000, + }); + + const { data: history = [] } = useQuery({ + queryKey: [`/api/comms/history?propertyId=${propertyId}`], + }); + + const sendMutation = useMutation({ + mutationFn: (data: Record) => + apiRequest('POST', '/api/comms/send', data).then(r => r.json()), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [`/api/comms/history?propertyId=${propertyId}`] }); + toast({ title: 'Message sent', description: `${channel.toUpperCase()} sent to ${recipientName || recipient}` }); + setMessage(''); setSubject(''); + }, + onError: (err: Error) => { + toast({ title: 'Send failed', description: err.message, variant: 'destructive' }); + }, + }); + + function handleSend() { + if (!recipient || !message) return; + sendMutation.mutate({ + channel, + to: recipient, + message: channel === 'email' ? message : message, + subject: channel === 'email' ? (subject || 'ChittyFinance Notification') : undefined, + recipientName, + propertyId, + template: template !== 'custom' ? template : undefined, + }); + } + + function selectTenant(leaseId: string) { + const lease = leases.find(l => l.id === leaseId); + if (!lease) return; + setRecipientName(lease.tenantName); + if (channel === 'sms' && lease.tenantPhone) { + setRecipient(lease.tenantPhone); + } else if (channel === 'email' && lease.tenantEmail) { + setRecipient(lease.tenantEmail); + } + } + + const configured = channel === 'sms' ? status?.twilio : status?.sendgrid; + + return ( +
+ {/* Send Message Card */} + + + + Send Message + + + +
+
+ + +
+
+ + +
+
+ + {/* Tenant Quick Select */} + {leases.length > 0 && ( +
+ + +
+ )} + +
+
+ + setRecipientName(e.target.value)} placeholder="John Smith" /> +
+
+ + setRecipient(e.target.value)} + placeholder={channel === 'sms' ? '+13125550100' : 'tenant@example.com'} /> +
+
+ + {channel === 'email' && ( +
+ + setSubject(e.target.value)} placeholder="Subject line" /> +
+ )} + +
+ +