diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 63ea29b..1cd4093 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,10 +1,12 @@ import { connection } from "next/server"; import { fetchDashboardNavbarData } from "@/features/dashboard/lib/navbar-queries"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; +import { AppPreferencesProvider } from "@/shared/components/providers/app-preferences-provider"; import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { getUserSession } from "@/shared/lib/auth/server"; import { isLogoDevEnabled } from "@/shared/lib/logo/server"; +import { fetchAppPreferences } from "@/shared/lib/preferences/queries"; export default async function DashboardLayout({ children, @@ -13,26 +15,32 @@ export default async function DashboardLayout({ }>) { await connection(); const session = await getUserSession(); - const navbarData = await fetchDashboardNavbarData(session.user.id); + const [navbarData, appPreferences] = await Promise.all([ + fetchDashboardNavbarData(session.user.id), + fetchAppPreferences(session.user.id), + ]); const logoDevEnabled = isLogoDevEnabled(); return ( - - -
-
-
- {children} + + + +
+
+
+ {children} +
-
- + + ); } diff --git a/src/features/dashboard/lib/navbar-queries.ts b/src/features/dashboard/lib/navbar-queries.ts index 6f7d02b..a4c2f3f 100644 --- a/src/features/dashboard/lib/navbar-queries.ts +++ b/src/features/dashboard/lib/navbar-queries.ts @@ -1,10 +1,13 @@ -import { eq } from "drizzle-orm"; +import { and, asc, eq, ilike, not, sql } from "drizzle-orm"; import { cacheLife, cacheTag } from "next/cache"; -import { payers } from "@/db/schema"; +import { cards, financialAccounts, payers, transactions } from "@/db/schema"; import { fetchPendingInboxCount } from "@/features/inbox/queries"; +import type { NavbarFinanceLinks } from "@/shared/components/navigation/navbar/nav-items"; +import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants"; import { db } from "@/shared/lib/db"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getBusinessDateString } from "@/shared/utils/date"; +import { safeToNumber } from "@/shared/utils/number"; import { type DashboardNotificationsSnapshot, fetchDashboardNotifications, @@ -14,13 +17,13 @@ type DashboardNavbarData = { payerAvatarUrl: string | null; inboxPendingCount: number; notificationsSnapshot: DashboardNotificationsSnapshot; + financeLinks: NavbarFinanceLinks; }; async function fetchAdminPayerAvatarUrl( userId: string, + adminPayerId: string | null, ): Promise { - const adminPayerId = await getAdminPayerId(userId); - if (!adminPayerId) { return null; } @@ -29,7 +32,7 @@ async function fetchAdminPayerAvatarUrl( columns: { avatarUrl: true, }, - where: eq(payers.id, adminPayerId), + where: and(eq(payers.id, adminPayerId), eq(payers.userId, userId)), }); return payer?.avatarUrl ?? null; @@ -39,17 +42,97 @@ async function fetchDashboardNavbarDataInternal( userId: string, ): Promise { const currentPeriod = getBusinessDateString().slice(0, 7); - const [payerAvatarUrl, notificationsSnapshot, inboxPendingCount] = - await Promise.all([ - fetchAdminPayerAvatarUrl(userId), - fetchDashboardNotifications(userId, currentPeriod), - fetchPendingInboxCount(userId), - ]); + const adminPayerId = await getAdminPayerId(userId); + const [ + payerAvatarUrl, + notificationsSnapshot, + inboxPendingCount, + activeCards, + activeAccounts, + ] = await Promise.all([ + fetchAdminPayerAvatarUrl(userId, adminPayerId), + fetchDashboardNotifications(userId, currentPeriod), + fetchPendingInboxCount(userId), + db + .select({ + id: cards.id, + name: cards.name, + logo: cards.logo, + amount: sql`coalesce(sum(${transactions.amount}), 0)`, + }) + .from(cards) + .leftJoin( + transactions, + and( + eq(transactions.cardId, cards.id), + eq(transactions.userId, userId), + eq(transactions.period, currentPeriod), + ), + ) + .where(and(eq(cards.userId, userId), not(ilike(cards.status, "inativo")))) + .groupBy(cards.id, cards.name, cards.logo) + .orderBy(asc(cards.name)), + db + .select({ + id: financialAccounts.id, + name: financialAccounts.name, + logo: financialAccounts.logo, + initialBalance: financialAccounts.initialBalance, + balanceMovements: sql` + coalesce( + sum( + case + when ${transactions.note} = ${INITIAL_BALANCE_NOTE} then 0 + else ${transactions.amount} + end + ), + 0 + ) + `, + }) + .from(financialAccounts) + .leftJoin( + transactions, + and( + eq(transactions.accountId, financialAccounts.id), + eq(transactions.userId, userId), + eq(transactions.isSettled, true), + adminPayerId ? eq(transactions.payerId, adminPayerId) : sql`false`, + ), + ) + .where( + and( + eq(financialAccounts.userId, userId), + not(ilike(financialAccounts.status, "inativa")), + ), + ) + .groupBy( + financialAccounts.id, + financialAccounts.name, + financialAccounts.logo, + financialAccounts.initialBalance, + ) + .orderBy(asc(financialAccounts.name)), + ]); return { payerAvatarUrl, inboxPendingCount, notificationsSnapshot, + financeLinks: { + cards: activeCards.map((card) => ({ + ...card, + amount: Math.abs(safeToNumber(card.amount)), + })), + accounts: activeAccounts.map((account) => ({ + id: account.id, + name: account.name, + logo: account.logo, + amount: + safeToNumber(account.initialBalance) + + safeToNumber(account.balanceMovements), + })), + }, }; } diff --git a/src/shared/components/month-picker/month-navigation.tsx b/src/shared/components/month-picker/month-navigation.tsx index 9c33ce8..b91e5e2 100644 --- a/src/shared/components/month-picker/month-navigation.tsx +++ b/src/shared/components/month-picker/month-navigation.tsx @@ -1,9 +1,22 @@ "use client"; +import { RiArrowDropDownLine, RiCalendarLine } from "@remixicon/react"; import { useRouter } from "next/navigation"; -import { useEffect, useTransition } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; +import { Button } from "@/shared/components/ui/button"; import { Card } from "@/shared/components/ui/card"; -import { getNextPeriod, getPreviousPeriod } from "@/shared/utils/period"; +import { MonthPicker } from "@/shared/components/ui/month-picker"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/components/ui/popover"; +import { + dateToPeriod, + getNextPeriod, + getPreviousPeriod, + periodToDate, +} from "@/shared/utils/period"; import LoadingSpinner from "./loading-spinner"; import NavigationButton from "./nav-button"; import ReturnButton from "./return-button"; @@ -15,6 +28,8 @@ export default function MonthNavigation() { const router = useRouter(); const [isPending, startTransition] = useTransition(); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const closePickerTimeout = useRef>(null); const currentMonthLabel = `${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`; const prevTarget = buildHref(getPreviousPeriod(period)); @@ -30,31 +45,88 @@ export default function MonthNavigation() { } }, [router, prevTarget, nextTarget, returnTarget, isDifferentFromCurrent]); + useEffect(() => { + return () => { + if (closePickerTimeout.current) { + clearTimeout(closePickerTimeout.current); + } + }; + }, []); + const handleNavigate = (href: string) => { + setIsPickerOpen(false); startTransition(() => { router.replace(href, { scroll: false }); }); }; + const handlePickerOpen = () => { + if (isPending) { + return; + } + if (closePickerTimeout.current) { + clearTimeout(closePickerTimeout.current); + } + setIsPickerOpen(true); + }; + + const handlePickerClose = () => { + closePickerTimeout.current = setTimeout(() => { + setIsPickerOpen(false); + }, 150); + }; + + const handleMonthSelect = (date: Date) => { + handleNavigate(buildHref(dateToPeriod(date))); + }; + return ( - -
+ +
handleNavigate(prevTarget)} /> -
-
- {currentMonthLabel} -
- - {isPending && } +
+ + + + + + + +
- - + + ); } diff --git a/src/shared/components/month-picker/return-button.tsx b/src/shared/components/month-picker/return-button.tsx index af4c2da..6ab0cdb 100644 --- a/src/shared/components/month-picker/return-button.tsx +++ b/src/shared/components/month-picker/return-button.tsx @@ -1,5 +1,6 @@ "use client"; +import { RiCalendarLine } from "@remixicon/react"; import { Button } from "@/shared/components/ui/button"; interface ReturnButtonProps { @@ -10,13 +11,16 @@ interface ReturnButtonProps { export default function ReturnButton({ disabled, onClick }: ReturnButtonProps) { return ( ); } diff --git a/src/shared/components/navigation/navbar/app-navbar.tsx b/src/shared/components/navigation/navbar/app-navbar.tsx index 97f2d1d..93bc961 100644 --- a/src/shared/components/navigation/navbar/app-navbar.tsx +++ b/src/shared/components/navigation/navbar/app-navbar.tsx @@ -3,6 +3,7 @@ import { NotificationBell } from "@/shared/components/navigation/navbar/notifica import { RefreshPageButton } from "@/shared/components/refresh-page-button"; import type { DashboardNotificationsSnapshot } from "@/shared/lib/types/notifications"; import { checkForUpdate } from "@/shared/lib/version/check-update"; +import type { NavbarFinanceLinks } from "./nav-items"; import { NavMenu } from "./nav-menu"; import { NavbarShell } from "./navbar-shell"; import { NavbarUser } from "./navbar-user"; @@ -17,6 +18,7 @@ type AppNavbarProps = { payerAvatarUrl: string | null; inboxPendingCount?: number; notificationsSnapshot: DashboardNotificationsSnapshot; + financeLinks: NavbarFinanceLinks; }; export async function AppNavbar({ @@ -24,12 +26,13 @@ export async function AppNavbar({ payerAvatarUrl, inboxPendingCount = 0, notificationsSnapshot, + financeLinks, }: AppNavbarProps) { const updateCheck = await checkForUpdate(); return ( - +
); } + +export function MobileFinanceEntityLinks({ + type, + items, + onClick, +}: { + type: keyof NavbarFinanceLinks; + items: NavbarEntityLink[]; + onClick: () => void; +}) { + const pathname = usePathname(); + + return items.map((item) => { + const href = + type === "cards" + ? `/cards/${item.id}/invoice` + : `/accounts/${item.id}/statement`; + const logoSrc = resolveLogoSrc(item.logo); + const isActive = pathname === href; + const fallbackIcon = + type === "cards" ? ( + + ) : ( + + ); + + return ( + + {logoSrc ? ( + + ) : ( + {fallbackIcon} + )} + + + {item.name} + + + {type === "cards" ? "Fatura deste mês" : "Saldo"}:{" "} + {formatCurrency(item.amount)} + + + + ); + }); +} diff --git a/src/shared/components/navigation/navbar/nav-dropdown.tsx b/src/shared/components/navigation/navbar/nav-dropdown.tsx index a80a79b..d8e5ab9 100644 --- a/src/shared/components/navigation/navbar/nav-dropdown.tsx +++ b/src/shared/components/navigation/navbar/nav-dropdown.tsx @@ -1,16 +1,85 @@ "use client"; +import { + RiArrowRightSLine, + RiBankCard2Line, + RiBankLine, +} from "@remixicon/react"; import { usePathname } from "next/navigation"; import { Badge } from "@/shared/components/ui/badge"; +import { resolveLogoSrc } from "@/shared/lib/logo"; +import { formatCurrency } from "@/shared/utils/currency"; import { cn } from "@/shared/utils/ui"; -import type { NavItem } from "./nav-items"; +import type { + NavbarEntityLink, + NavbarFinanceLinks, + NavItem, +} from "./nav-items"; import { NavLink } from "./nav-link"; type NavDropdownProps = { items: NavItem[]; + financeLinks?: NavbarFinanceLinks; }; -export function NavDropdown({ items }: NavDropdownProps) { +function FinanceEntityLinks({ + type, + items, +}: { + type: keyof NavbarFinanceLinks; + items: NavbarEntityLink[]; +}) { + const pathname = usePathname(); + + return items.map((item) => { + const href = + type === "cards" + ? `/cards/${item.id}/invoice` + : `/accounts/${item.id}/statement`; + const logoSrc = resolveLogoSrc(item.logo); + const isActive = pathname === href; + const fallbackIcon = + type === "cards" ? ( + + ) : ( + + ); + + return ( +
  • + + {logoSrc ? ( + + ) : ( + {fallbackIcon} + )} + + {item.name} + + {type === "cards" ? "Fatura deste mês" : "Saldo"}:{" "} + {formatCurrency(item.amount)} + + + +
  • + ); + }); +} + +export function NavDropdown({ items, financeLinks }: NavDropdownProps) { const pathname = usePathname(); return ( @@ -18,9 +87,22 @@ export function NavDropdown({ items }: NavDropdownProps) { {items.map((item) => { const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`); + const entityLinks = + item.href === "/cards" + ? financeLinks?.cards + : item.href === "/accounts" + ? financeLinks?.accounts + : undefined; + const entityType = + item.href === "/cards" + ? "cards" + : item.href === "/accounts" + ? "accounts" + : undefined; + const hasEntityLinks = Boolean(entityType && entityLinks?.length); return ( -
  • +
  • ) : null} + {hasEntityLinks ? ( + + ) : null} + {hasEntityLinks && entityType && entityLinks ? ( +
    +
      + +
    +
    + ) : null}
  • ); })} diff --git a/src/shared/components/navigation/navbar/nav-items.tsx b/src/shared/components/navigation/navbar/nav-items.tsx index bc23eab..5223879 100644 --- a/src/shared/components/navigation/navbar/nav-items.tsx +++ b/src/shared/components/navigation/navbar/nav-items.tsx @@ -26,6 +26,18 @@ export type NavItem = { hideOnMobile?: boolean; }; +export type NavbarEntityLink = { + id: string; + name: string; + logo: string | null; + amount: number; +}; + +export type NavbarFinanceLinks = { + cards: NavbarEntityLink[]; + accounts: NavbarEntityLink[]; +}; + type NavSection = { label: string; items: NavItem[]; diff --git a/src/shared/components/navigation/navbar/nav-menu.tsx b/src/shared/components/navigation/navbar/nav-menu.tsx index 4c4af7b..1aee54a 100644 --- a/src/shared/components/navigation/navbar/nav-menu.tsx +++ b/src/shared/components/navigation/navbar/nav-menu.tsx @@ -22,9 +22,13 @@ import { SheetTrigger, } from "@/shared/components/ui/sheet"; import { cn } from "@/shared/utils/ui"; -import { MobileLink, MobileSectionLabel } from "./mobile-link"; +import { + MobileFinanceEntityLinks, + MobileLink, + MobileSectionLabel, +} from "./mobile-link"; import { NavDropdown } from "./nav-dropdown"; -import { NAV_SECTIONS } from "./nav-items"; +import { NAV_SECTIONS, type NavbarFinanceLinks } from "./nav-items"; import { NavPill } from "./nav-pill"; import { MobileTools, NavToolsDropdown } from "./nav-tools"; @@ -34,7 +38,11 @@ const triggerClass = const triggerActiveClass = "bg-primary-foreground/15! text-primary-foreground! dark:bg-foreground/15! dark:text-foreground!"; -export function NavMenu() { +export function NavMenu({ + financeLinks, +}: { + financeLinks: NavbarFinanceLinks; +}) { const pathname = usePathname(); const [sheetOpen, setSheetOpen] = useState(false); const [calculatorOpen, setCalculatorOpen] = useState(false); @@ -73,8 +81,19 @@ export function NavMenu() { > {section.label} - - + + ); @@ -130,17 +149,33 @@ export function NavMenu() {
    {mobileItems.map((item) => ( - - {item.label} - +
    + + {item.label} + + {item.href === "/cards" && financeLinks.cards.length ? ( + + ) : null} + {item.href === "/accounts" && + financeLinks.accounts.length ? ( + + ) : null} +
    ))}
    );