feat(ledger): add bookkeeping workbench and transaction views

- Add transactions_dirty table and zero-tolerance cleansing action

- Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier

- Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters

- Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
2026-09-06 23:23:29 +08:00
parent 1ccee1414c
commit c33857ffac
42 changed files with 3329 additions and 598 deletions
+2 -4
View File
@@ -7,9 +7,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import {
SidebarTrigger,
} from "@/components/ui/sidebar"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { ThemeToggle } from "@/components/theme-toggle"
export function AppHeader({ title }: { title: string }) {
@@ -17,7 +15,7 @@ export function AppHeader({ title }: { title: string }) {
return (
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex min-w-0 h-5 items-center gap-2">
<div className="flex h-5 min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2" />
<Breadcrumb>
+26 -14
View File
@@ -1,16 +1,18 @@
"use client";
"use client"
import * as React from "react";
import Link from "next/link";
import * as React from "react"
import Link from "next/link"
import {
CreditCardIcon,
LayoutDashboardIcon,
PenToolIcon,
ReceiptTextIcon,
WalletCardsIcon,
} from "lucide-react";
} from "lucide-react"
import { FluxentLogo } from "@/components/fluxent-logo";
import { NavMain, type NavMainItem } from "@/components/nav-main";
import { NavUser, type NavUserData } from "@/components/nav-user";
import { FluxentLogo } from "@/components/fluxent-logo"
import { NavMain, type NavMainItem } from "@/components/nav-main"
import { NavUser, type NavUserData } from "@/components/nav-user"
import {
Sidebar,
SidebarContent,
@@ -20,7 +22,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
} from "@/components/ui/sidebar"
const navMainItems: NavMainItem[] = [
{
@@ -28,6 +30,16 @@ const navMainItems: NavMainItem[] = [
url: "/",
icon: LayoutDashboardIcon,
},
{
title: "流水记账",
url: "/bookkeeping",
icon: PenToolIcon,
},
{
title: "交易记录",
url: "/transactions",
icon: ReceiptTextIcon,
},
{
title: "资金账户",
url: "/accounts",
@@ -38,21 +50,21 @@ const navMainItems: NavMainItem[] = [
url: "/channels",
icon: CreditCardIcon,
},
];
]
export function AppSidebar({
user,
...props
}: {
user: NavUserData;
user: NavUserData
} & React.ComponentProps<typeof Sidebar>) {
const { isMobile, setOpenMobile } = useSidebar();
const { isMobile, setOpenMobile } = useSidebar()
const handleLogoNavigation = () => {
if (isMobile) {
setOpenMobile(false);
setOpenMobile(false)
}
};
}
return (
<Sidebar variant="inset" {...props}>
@@ -89,5 +101,5 @@ export function AppSidebar({
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
);
)
}
+5 -5
View File
@@ -1,12 +1,12 @@
import React from "react";
import { cn } from "@/lib/utils";
import React from "react"
import { cn } from "@/lib/utils"
export function FluxentLogo({
className,
size = 36,
}: {
className?: string;
size?: number;
className?: string
size?: number
}) {
return (
<div
@@ -44,5 +44,5 @@ export function FluxentLogo({
<circle cx="15" cy="20" r="1.5" fill="currentColor" fillOpacity="0.8" />
</svg>
</div>
);
)
}
+18 -17
View File
@@ -1,8 +1,8 @@
"use client";
"use client"
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type LucideIcon } from "lucide-react";
import Link from "next/link"
import { usePathname } from "next/navigation"
import { type LucideIcon } from "lucide-react"
import {
SidebarGroup,
@@ -11,24 +11,24 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
} from "@/components/ui/sidebar"
export interface NavMainItem {
title: string;
url: string;
icon: LucideIcon;
badge?: string;
title: string
url: string
icon: LucideIcon
badge?: string
}
export function NavMain({ items }: { items: NavMainItem[] }) {
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
const handleNavigation = () => {
if (isMobile) {
setOpenMobile(false);
setOpenMobile(false)
}
};
}
return (
<SidebarGroup>
@@ -37,14 +37,15 @@ export function NavMain({ items }: { items: NavMainItem[] }) {
</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon;
const Icon = item.icon
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={
item.url === "/"
? pathname === "/"
: pathname === item.url || pathname.startsWith(`${item.url}/`)
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
tooltip={item.title}
render={<Link href={item.url} onClick={handleNavigation} />}
@@ -58,9 +59,9 @@ export function NavMain({ items }: { items: NavMainItem[] }) {
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
)
})}
</SidebarMenu>
</SidebarGroup>
);
)
}
+17 -17
View File
@@ -1,8 +1,8 @@
"use client";
"use client"
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type LucideIcon } from "lucide-react";
import Link from "next/link"
import { usePathname } from "next/navigation"
import { type LucideIcon } from "lucide-react"
import {
SidebarGroup,
@@ -11,13 +11,13 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
} from "@/components/ui/sidebar"
export interface NavSecondaryItem {
title: string;
url: string;
icon: LucideIcon;
badge?: React.ReactNode;
title: string
url: string
icon: LucideIcon
badge?: React.ReactNode
}
export function NavSecondary({
@@ -25,23 +25,23 @@ export function NavSecondary({
className,
...props
}: {
items: NavSecondaryItem[];
items: NavSecondaryItem[]
} & React.ComponentProps<typeof SidebarGroup>) {
const pathname = usePathname();
const { isMobile, setOpenMobile } = useSidebar();
const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
const handleNavigation = () => {
if (isMobile) {
setOpenMobile(false);
setOpenMobile(false)
}
};
}
return (
<SidebarGroup className={className} {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const Icon = item.icon;
const Icon = item.icon
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
@@ -56,10 +56,10 @@ export function NavSecondary({
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
)
}
+16 -22
View File
@@ -1,4 +1,4 @@
"use client";
"use client"
import {
ChevronsUpDownIcon,
@@ -6,14 +6,10 @@ import {
Settings2Icon,
SparklesIcon,
UserCogIcon,
} from "lucide-react";
import { signOut } from "next-auth/react";
} from "lucide-react"
import { signOut } from "next-auth/react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,29 +18,27 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
} from "@/components/ui/sidebar"
export interface NavUserData {
name: string;
email: string;
avatar?: string | null;
name: string
email: string
avatar?: string | null
}
export function NavUser({ user }: { user: NavUserData }) {
const { isMobile } = useSidebar();
const initials = (user.name || user.email || "U")
.slice(0, 2)
.toUpperCase();
const { isMobile } = useSidebar()
const initials = (user.name || user.email || "U").slice(0, 2).toUpperCase()
const handleSignOut = () => {
signOut({ callbackUrl: "/login" });
};
signOut({ callbackUrl: "/login" })
}
return (
<SidebarMenu>
@@ -62,7 +56,7 @@ export function NavUser({ user }: { user: NavUserData }) {
{user.avatar ? (
<AvatarImage src={user.avatar} alt={user.name} />
) : null}
<AvatarFallback className="rounded-lg font-medium text-xs">
<AvatarFallback className="rounded-lg text-xs font-medium">
{initials}
</AvatarFallback>
</Avatar>
@@ -89,7 +83,7 @@ export function NavUser({ user }: { user: NavUserData }) {
{user.avatar ? (
<AvatarImage src={user.avatar} alt={user.name} />
) : null}
<AvatarFallback className="rounded-lg font-medium text-xs">
<AvatarFallback className="rounded-lg text-xs font-medium">
{initials}
</AvatarFallback>
</Avatar>
@@ -140,5 +134,5 @@ export function NavUser({ user }: { user: NavUserData }) {
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
)
}
+11 -12
View File
@@ -1,17 +1,17 @@
"use client";
"use client"
import * as React from "react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import * as React from "react"
import { useTheme } from "next-themes"
import { MoonIcon, SunIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const { resolvedTheme, setTheme } = useTheme()
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false
);
)
if (!mounted) {
return (
@@ -23,10 +23,10 @@ export function ThemeToggle() {
>
<span className="size-4" />
</Button>
);
)
}
const isDark = resolvedTheme === "dark";
const isDark = resolvedTheme === "dark"
return (
<Button
@@ -35,7 +35,7 @@ export function ThemeToggle() {
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label="Toggle color theme"
title={isDark ? "切换为浅色模式" : "切换为深色模式"}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground transition-colors hover:text-foreground"
>
{isDark ? (
<SunIcon data-icon="inline-start" />
@@ -43,6 +43,5 @@ export function ThemeToggle() {
<MoonIcon data-icon="inline-start" />
)}
</Button>
);
)
}
+2 -5
View File
@@ -84,9 +84,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
{children ?? <ChevronRightIcon />}
</li>
)
}
@@ -106,8 +104,7 @@ function BreadcrumbEllipsis({
)}
{...props}
>
<MoreHorizontalIcon
/>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
)
+8 -8
View File
@@ -1,9 +1,9 @@
"use client";
"use client"
import * as React from "react";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
@@ -13,7 +13,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input bg-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-primary data-[checked]:border-primary data-[checked]:text-primary-foreground",
"peer size-4 shrink-0 rounded-[4px] border border-input bg-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground",
className
)}
{...props}
@@ -22,7 +22,7 @@ function Checkbox({
<CheckIcon data-icon="inline-start" className="size-3.5 stroke-[2.5]" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
)
}
export { Checkbox };
export { Checkbox }
+4 -1
View File
@@ -110,7 +110,10 @@ function ComboboxContent({
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
+1 -2
View File
@@ -70,8 +70,7 @@ function DialogContent({
/>
}
>
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
+10 -6
View File
@@ -40,7 +40,10 @@ function DropdownMenuContent({
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
@@ -134,7 +137,10 @@ function DropdownMenuSubContent({
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
@@ -169,8 +175,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
@@ -210,8 +215,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
+6 -5
View File
@@ -82,7 +82,10 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
<SelectScrollUpButton />
@@ -161,8 +164,7 @@ function SelectScrollUpButton({
)}
{...props}
>
<ChevronUpIcon
/>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
)
}
@@ -180,8 +182,7 @@ function SelectScrollDownButton({
)}
{...props}
>
<ChevronDownIcon
/>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
)
}
+1 -2
View File
@@ -70,8 +70,7 @@ function SheetContent({
/>
}
>
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}