mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
e302fb91f0
- Remove the Zoho Books integration (edge functions, sync libs, settings, reports/overview, banking links, fees tab, import dialog); preserve fee rules as a standalone FeesTab and the COA accounting_system classification. - Financial Overview/Reports (staff + board) render the Accounting dashboard and reports; board reports mirror the rich Accounting Reports. - New Reserve Fund Schedule report + an is_reserve flag on accounts. - Unify all report exports to a branded format (logo + centered header + footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs Actuals and Bank Reconciliation PDFs now match the reference layout. - Render financial reports inline (no preview pop-up). - Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA navigation; editable bills in the Accounting Bills page. - Negative opening balances flow through to the GL and reports (allow negative input; keep non-zero on save; signed CSV import). - Upload a per-account trial balance via CSV on Opening Balances. - Board members: read-only RLS access to their association's accounting ledger; editable board-members panel on the association page; share vendor contacts with the board (toggle + directory section). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
444 lines
18 KiB
TypeScript
444 lines
18 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Search, Settings, DollarSign, Users, Phone, Plus, User, Eye, LogOut, Shield, Building2, Home, FileText, AlertTriangle, Loader2, Database, MessageCircle, Landmark, ExternalLink } from "lucide-react";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { CallLogDialog } from "@/components/CallLogDialog";
|
|
import { NotificationBell } from "@/components/NotificationBell";
|
|
import { MessagesIconButton } from "@/components/MessagesIconButton";
|
|
import { TimerPopover } from "@/components/dashboard/TimerPopover";
|
|
import { DateCalculatorPopover } from "@/components/dashboard/DateCalculatorPopover";
|
|
import UnitLedgerTransactionForm from "@/components/unit-profile/UnitLedgerTransactionForm";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import {
|
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
|
DropdownMenuSeparator, DropdownMenuTrigger
|
|
} from "@/components/ui/dropdown-menu";
|
|
import type { Enums } from "@/integrations/supabase/types";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface DashboardHeaderProps {
|
|
userId?: string;
|
|
userEmail?: string;
|
|
fullName?: string;
|
|
roles?: Enums<"app_role">[];
|
|
}
|
|
|
|
interface SearchResult {
|
|
id: string;
|
|
type: "owner" | "unit" | "association" | "violation" | "invoice" | "shortcut";
|
|
title: string;
|
|
subtitle: string;
|
|
route: string;
|
|
}
|
|
|
|
const typeIcons: Record<SearchResult["type"], React.ReactNode> = {
|
|
owner: <Users className="h-3.5 w-3.5 text-primary" />,
|
|
unit: <Home className="h-3.5 w-3.5 text-primary" />,
|
|
association: <Building2 className="h-3.5 w-3.5 text-primary" />,
|
|
violation: <AlertTriangle className="h-3.5 w-3.5 text-destructive" />,
|
|
invoice: <FileText className="h-3.5 w-3.5 text-primary" />,
|
|
shortcut: <Database className="h-3.5 w-3.5 text-primary" />,
|
|
};
|
|
|
|
export function DashboardHeader({ userEmail, fullName, roles = [], userId }: DashboardHeaderProps) {
|
|
const navigate = useNavigate();
|
|
const displayName = fullName || userEmail || "User";
|
|
const initials = fullName
|
|
? fullName.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
|
|
: userEmail
|
|
? userEmail.slice(0, 2).toUpperCase()
|
|
: "U";
|
|
|
|
const primaryRole = roles[0];
|
|
const roleLabel = primaryRole
|
|
? primaryRole.charAt(0).toUpperCase() + primaryRole.slice(1)
|
|
: undefined;
|
|
|
|
const { toast } = useToast();
|
|
const [callLogOpen, setCallLogOpen] = useState(false);
|
|
const [chargePaymentOpen, setChargePaymentOpen] = useState(false);
|
|
const [profileOpen, setProfileOpen] = useState(false);
|
|
const [profileForm, setProfileForm] = useState({ full_name: fullName || "", email: userEmail || "" });
|
|
const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
|
|
|
|
// Global search state
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
const [showResults, setShowResults] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const searchRef = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
useEffect(() => {
|
|
supabase.from("associations").select("id, name, zoho_organization_id").eq("status", "active").order("name").then(({ data }) => {
|
|
setAssociations(data || []);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setProfileForm({ full_name: fullName || "", email: userEmail || "" });
|
|
}, [fullName, userEmail]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
|
setShowResults(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const performSearch = useCallback(async (query: string) => {
|
|
if (query.length < 2) {
|
|
setSearchResults([]);
|
|
setShowResults(false);
|
|
return;
|
|
}
|
|
setSearchLoading(true);
|
|
setShowResults(true);
|
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const term = `%${query}%`;
|
|
const results: SearchResult[] = [];
|
|
|
|
try {
|
|
const [ownersRes, unitsRes, assocsRes, violationsRes, invoicesRes] = await Promise.all([
|
|
supabase
|
|
.from("owners")
|
|
.select("id, first_name, last_name, associations(name), units(account_number)")
|
|
.or(`first_name.ilike.${term},last_name.ilike.${term}`)
|
|
.limit(5),
|
|
supabase
|
|
.from("units")
|
|
.select("id, unit_number, address, associations(name)")
|
|
.or(`unit_number.ilike.${term},address.ilike.${term}`)
|
|
.limit(5),
|
|
supabase
|
|
.from("associations")
|
|
.select("id, name, address")
|
|
.ilike("name", term)
|
|
.limit(5),
|
|
supabase
|
|
.from("violations")
|
|
.select("id, violation_type, status, associations(name)")
|
|
.or(`violation_type.ilike.${term},status.ilike.${term}`)
|
|
.limit(5),
|
|
supabase
|
|
.from("invoices")
|
|
.select("id, invoice_number, owner_name, associations(name)")
|
|
.or(`invoice_number.ilike.${term},owner_name.ilike.${term}`)
|
|
.limit(5),
|
|
]);
|
|
|
|
(ownersRes.data || []).forEach((o: any) => {
|
|
results.push({
|
|
id: o.id,
|
|
type: "owner",
|
|
title: `${o.first_name || ""} ${o.last_name || ""}`.trim() || "Unknown",
|
|
subtitle: `${o.associations?.name || "No association"} • ${o.units?.account_number || "No acct"}`,
|
|
route: `/dashboard/owner-roster/${o.id}`,
|
|
});
|
|
});
|
|
|
|
(unitsRes.data || []).forEach((u: any) => {
|
|
results.push({
|
|
id: u.id,
|
|
type: "unit",
|
|
title: `Unit ${u.unit_number}`,
|
|
subtitle: `${u.associations?.name || ""} • ${u.address || ""}`.trim(),
|
|
route: `/dashboard/units/${u.id}`,
|
|
});
|
|
});
|
|
|
|
(assocsRes.data || []).forEach((a: any) => {
|
|
results.push({
|
|
id: a.id,
|
|
type: "association",
|
|
title: a.name,
|
|
subtitle: a.address || "No address",
|
|
route: `/dashboard/associations/${a.id}`,
|
|
});
|
|
});
|
|
|
|
(violationsRes.data || []).forEach((v: any) => {
|
|
results.push({
|
|
id: v.id,
|
|
type: "violation",
|
|
title: v.violation_type || "Violation",
|
|
subtitle: `${v.associations?.name || ""} • ${v.status || ""}`,
|
|
route: `/dashboard/violations`,
|
|
});
|
|
});
|
|
|
|
(invoicesRes.data || []).forEach((inv: any) => {
|
|
results.push({
|
|
id: inv.id,
|
|
type: "invoice",
|
|
title: `Invoice ${inv.invoice_number}`,
|
|
subtitle: `${inv.owner_name || ""} • ${inv.associations?.name || ""}`,
|
|
route: `/dashboard/invoices`,
|
|
});
|
|
});
|
|
|
|
const shouldShowMigrationShortcut = roles.includes("admin") && (
|
|
normalizedQuery.includes("migration") ||
|
|
normalizedQuery.includes("migrate") ||
|
|
normalizedQuery.includes("data import") ||
|
|
normalizedQuery.includes("import data")
|
|
);
|
|
|
|
if (shouldShowMigrationShortcut) {
|
|
results.unshift({
|
|
id: "data-migration-shortcut",
|
|
type: "shortcut",
|
|
title: "Data Migration",
|
|
subtitle: "Open the admin migration tool",
|
|
route: "/dashboard/data-migration",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("Search error:", err);
|
|
}
|
|
|
|
setSearchResults(results);
|
|
setSelectedIndex(-1);
|
|
setSearchLoading(false);
|
|
}, [roles]);
|
|
|
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const val = e.target.value;
|
|
setSearchQuery(val);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => performSearch(val), 300);
|
|
};
|
|
|
|
const handleSelectResult = (result: SearchResult) => {
|
|
setShowResults(false);
|
|
setSearchQuery("");
|
|
setSearchResults([]);
|
|
navigate(result.route);
|
|
};
|
|
|
|
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (!showResults || searchResults.length === 0) return;
|
|
handleSelectResult(searchResults[selectedIndex >= 0 ? selectedIndex : 0]);
|
|
return;
|
|
}
|
|
|
|
if (!showResults || searchResults.length === 0) return;
|
|
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => Math.min(prev + 1, searchResults.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
} else if (e.key === "Escape") {
|
|
setShowResults(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveProfile = async () => {
|
|
if (!userId) return;
|
|
const { error } = await supabase
|
|
.from("profiles")
|
|
.update({ full_name: profileForm.full_name })
|
|
.eq("user_id", userId);
|
|
if (error) {
|
|
toast({ title: "Error", description: "Failed to update profile", variant: "destructive" });
|
|
} else {
|
|
toast({ title: "Profile updated" });
|
|
setProfileOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleSignOut = async () => {
|
|
await supabase.auth.signOut();
|
|
navigate("/");
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<header className="sticky top-0 z-30 bg-card border-b border-border">
|
|
<div className="flex h-12 items-center gap-3 px-4">
|
|
<SidebarTrigger className="text-muted-foreground hover:text-foreground" />
|
|
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md" ref={searchRef}>
|
|
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/40 z-10" />
|
|
<Input
|
|
placeholder="Search owners, units, properties, invoices, violations…"
|
|
className="h-8 pl-9 text-[13px] bg-muted/50 border-transparent focus:border-border focus:bg-card transition-colors rounded-lg"
|
|
value={searchQuery}
|
|
onChange={handleSearchChange}
|
|
onKeyDown={handleSearchKeyDown}
|
|
onFocus={() => { if (searchResults.length > 0) setShowResults(true); }}
|
|
/>
|
|
{showResults && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-lg shadow-lg overflow-hidden z-50 max-h-[360px] overflow-y-auto">
|
|
{searchLoading ? (
|
|
<div className="flex items-center justify-center py-6 gap-2 text-muted-foreground text-[13px]">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Searching…
|
|
</div>
|
|
) : searchResults.length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground text-[13px]">
|
|
No results found for "{searchQuery}"
|
|
</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{searchResults.map((result, idx) => (
|
|
<button
|
|
key={`${result.type}-${result.id}`}
|
|
className={`w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-accent/50 transition-colors ${
|
|
idx === selectedIndex ? "bg-accent/50" : ""
|
|
}`}
|
|
onClick={() => handleSelectResult(result)}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="shrink-0">{typeIcons[result.type]}</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[13px] font-medium text-foreground truncate">{result.title}</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">{result.subtitle}</p>
|
|
</div>
|
|
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium">
|
|
{result.type}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick actions */}
|
|
<div className="flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button size="sm" className="h-7 gap-1.5 text-[12px] rounded-lg">
|
|
<Plus className="h-3.5 w-3.5" /> Quick Action
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem onClick={() => setChargePaymentOpen(true)}>
|
|
<DollarSign className="h-3.5 w-3.5 mr-2" /> New Charge / Payment
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setCallLogOpen(true)}>
|
|
<Phone className="h-3.5 w-3.5 mr-2" /> New Call Log
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => navigate("/dashboard/notify-owners")}>
|
|
<Users className="h-3.5 w-3.5 mr-2" /> Notify Owners
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Utilities */}
|
|
<div className="flex items-center gap-1 border-l border-border pl-2">
|
|
{roles.includes("admin") && <TimerPopover userId={userId} associations={associations} />}
|
|
{roles.includes("admin") && <DateCalculatorPopover />}
|
|
<MessagesIconButton to="/dashboard/messages" />
|
|
<NotificationBell userId={userId} />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground" onClick={() => navigate("/dashboard/settings/branding")}>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Settings</TooltipContent>
|
|
</Tooltip>
|
|
|
|
</div>
|
|
|
|
{/* User */}
|
|
<div className="flex items-center gap-2 border-l border-border pl-3">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer outline-none">
|
|
<Avatar className="h-7 w-7">
|
|
<AvatarFallback className="bg-primary/10 text-primary text-[11px] font-semibold">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="hidden md:flex flex-col leading-tight text-left">
|
|
<span className="text-[13px] font-medium truncate max-w-[120px]">{displayName}</span>
|
|
{roleLabel && (
|
|
<span className="text-[11px] text-muted-foreground">{roleLabel}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52">
|
|
<div className="px-3 py-2 border-b border-border mb-1">
|
|
<p className="text-[13px] font-medium truncate">{displayName}</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">{userEmail}</p>
|
|
</div>
|
|
<DropdownMenuItem onClick={() => setProfileOpen(true)} className="text-[13px] gap-2">
|
|
<User className="h-3.5 w-3.5" /> Update Profile
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => navigate("/homeowner")} className="text-[13px] gap-2">
|
|
<Eye className="h-3.5 w-3.5" /> View as Homeowner
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => navigate("/homeowner")} className="text-[13px] gap-2">
|
|
<Shield className="h-3.5 w-3.5" /> View as Board Member
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => navigate("/dashboard/settings")} className="text-[13px] gap-2">
|
|
<Settings className="h-3.5 w-3.5" /> Settings
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={handleSignOut} className="text-[13px] gap-2 text-destructive focus:text-destructive">
|
|
<LogOut className="h-3.5 w-3.5" /> Sign Out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<CallLogDialog open={callLogOpen} onOpenChange={setCallLogOpen} associations={associations} />
|
|
<UnitLedgerTransactionForm open={chargePaymentOpen} onOpenChange={setChargePaymentOpen} />
|
|
|
|
{/* Profile Dialog */}
|
|
<Dialog open={profileOpen} onOpenChange={setProfileOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-[15px] font-semibold">Update Profile</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[12px]">Full Name</Label>
|
|
<Input
|
|
value={profileForm.full_name}
|
|
onChange={(e) => setProfileForm(prev => ({ ...prev, full_name: e.target.value }))}
|
|
className="h-8 text-[13px]"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[12px]">Email</Label>
|
|
<Input value={profileForm.email} disabled className="h-8 text-[13px] bg-muted/50" />
|
|
<p className="text-[11px] text-muted-foreground">Email cannot be changed here.</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" size="sm" className="h-8 text-[12px]" onClick={() => setProfileOpen(false)}>Cancel</Button>
|
|
<Button size="sm" className="h-8 text-[12px]" onClick={handleSaveProfile}>Save Changes</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|