Files
acmcc/src/components/dashboard/DashboardHeader.tsx
T
admin e302fb91f0 Accounting platform: remove Zoho, unify reports, board access, vendor sharing
- 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>
2026-06-02 18:29:31 -04:00

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>
</>
);
}