diff --git a/api/__tests__/accounts-coa-integration.test.ts b/api/__tests__/accounts-coa-integration.test.ts index 742e2aa..5ba1564 100644 --- a/api/__tests__/accounts-coa-integration.test.ts +++ b/api/__tests__/accounts-coa-integration.test.ts @@ -1,7 +1,7 @@ // ABOUTME: Verifies operational account creation stays simple while enforcing valid chart-account behavior. // ABOUTME: Protects location-scoped payment method links from crossing business and branch boundaries. import { afterEach, describe, expect, it } from "vitest"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { appRouter } from "../router"; import { @@ -162,8 +162,25 @@ describe("operational accounts and payment-method linking", () => { locationId: ctx.location.id, businessId: ctx.business.id, type: "cash", + }); + + expect(account.accountType).toBeNull(); + expect(account.accountSubType).toBeNull(); + + const sysAccounts = await db + .select() + .from(accounts) + .where(and( + eq(accounts.businessId, ctx.business.id), + eq(accounts.systemKey, "asset:cash"), + isNull(accounts.deletedAt), + )).limit(1); + + expect(sysAccounts.length).toBeGreaterThan(0); + expect(sysAccounts[0]).toMatchObject({ accountType: "asset", accountSubType: "cash", + isSystemGenerated: true, }); }); diff --git a/api/__tests__/business-reset.test.ts b/api/__tests__/business-reset.test.ts index a78d775..64a50d3 100644 --- a/api/__tests__/business-reset.test.ts +++ b/api/__tests__/business-reset.test.ts @@ -105,16 +105,6 @@ async function seedResetContext(seed: string): Promise { nextExpenseNumber: 27, } as any).returning()); - // Create expense category - const category = await firstRow(db.insert(expenseCategories).values({ - businessId: business.id, - locationId: location.id, - name: `Test Category ${seed}`, - color: "#C73E1D", - defaultAccountId: 0, - isActive: true, - } as any).returning()); - // Create operational (user) account const opAccount = await firstRow(db.insert(accounts).values({ businessId: business.id, @@ -141,10 +131,15 @@ async function seedResetContext(seed: string): Promise { isActive: true, } as any).returning()); - // Fix category defaultAccountId - await db.update(expenseCategories) - .set({ defaultAccountId: sysAccount.id }) - .where(eq(expenseCategories.id, category.id)); + // Create expense category (accounts must exist first due to FK constraint) + const category = await firstRow(db.insert(expenseCategories).values({ + businessId: business.id, + locationId: location.id, + name: `Test Category ${seed}`, + color: "#C73E1D", + defaultAccountId: sysAccount.id, + isActive: true, + } as any).returning()); // Create supplier const supplier = await firstRow(db.insert(suppliers).values({ @@ -255,10 +250,10 @@ async function seedResetContext(seed: string): Promise { totalPrice: "1000.00", } as any); - // Create M-PESA transaction + // Create M-PESA transaction (txnId limited to varchar(20)) await db.insert(mpesaTransactions).values({ locationId: location.id, - txnId: `MPESA-${seed}`, + txnId: `MPESA-${seed}`.slice(0, 20), txnDate: "2026-05-16", txnType: "topup", amount: "500.00", @@ -414,8 +409,9 @@ async function cleanupResetContext(accountId: string) { await db.delete(journalEntries).where(eq(journalEntries.businessId, business.id)); await db.delete(expenses).where(eq(expenses.businessId, business.id)); await db.delete(bills).where(eq(bills.businessId, business.id)); - await db.delete(accounts).where(eq(accounts.businessId, business.id)); + // Delete expense_categories FIRST (FK references accounts.id via defaultAccountId) await db.delete(expenseCategories).where(eq(expenseCategories.businessId, business.id)); + await db.delete(accounts).where(eq(accounts.businessId, business.id)); await db.delete(suppliers).where(eq(suppliers.businessId, business.id)); await db.delete(employees).where(sql`${employees.id} > 0`); await db.delete(locations).where(eq(locations.businessId, business.id)); @@ -455,7 +451,7 @@ describe("resetBusinessTransactions", () => { expect(result.success).toBe(true); expect(result.preserved).toContain("audit_log"); - expect(result.preserved).toContain("accounts (system)"); + expect(result.preserved).toContain("accounts (all)"); expect(result.resetAt).toBeTruthy(); // Verify system account preserved and balance reset @@ -641,11 +637,11 @@ describe("resetBusinessTransactions", () => { expect(snapshot.businessId).toBe(ctx.business.id); expect(snapshot.timestamp).toBeTruthy(); - expect(snapshot.tableCounts.dailySales).toBeGreaterThan(0); - expect(snapshot.tableCounts.expenses).toBeGreaterThan(0); - expect(snapshot.tableCounts.bills).toBeGreaterThan(0); - expect(snapshot.tableCounts.mpesaTransactions).toBeGreaterThan(0); - expect(snapshot.tableCounts.journalEntries).toBeGreaterThan(0); + expect(Number(snapshot.tableCounts.dailySales)).toBeGreaterThan(0); + expect(Number(snapshot.tableCounts.expenses)).toBeGreaterThan(0); + expect(Number(snapshot.tableCounts.bills)).toBeGreaterThan(0); + expect(Number(snapshot.tableCounts.mpesaTransactions)).toBeGreaterThan(0); + expect(Number(snapshot.tableCounts.journalEntries)).toBeGreaterThan(0); }); // ── Test 5: Return results structure ───────────────────────────────────── diff --git a/api/test/setup.ts b/api/test/setup.ts index 7349e79..6e88e60 100644 --- a/api/test/setup.ts +++ b/api/test/setup.ts @@ -101,6 +101,21 @@ async function ensureTestDatabase(): Promise { // continue with the next statement for idempotent setup. } } + + const migration2Path = path.resolve( + import.meta.dirname, + "../../db/migrations/0002_soft_flamingo.sql", + ); + let migration2Sql = fs.readFileSync(migration2Path, "utf8").replaceAll("--> statement-breakpoint", ""); + const migration2Statements = migration2Sql.split(";").filter((s) => s.trim()); + for (const stmt of migration2Statements) { + try { + await testPool.query(stmt); + } catch { + // Individual DDL statements may already exist; + // continue with the next statement for idempotent setup. + } + } } finally { await testPool.end(); } diff --git a/resources/1-Dashboard-uai-258x470.png b/resources/1-Dashboard-uai-258x470.png deleted file mode 100644 index 63bf150..0000000 Binary files a/resources/1-Dashboard-uai-258x470.png and /dev/null differ diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c6ece43..2c23b71 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -5,12 +5,14 @@ import { trpc } from "@/providers/trpc"; import { LayoutDashboard, Receipt, TrendingDown, Users, FileText, CreditCard, CalendarDays, Smartphone, Menu, X, LogOut, - Building2, ChevronRight, FileSpreadsheet, BookOpen, - Wallet, ShieldCheck, Settings, Briefcase, - Building, Bell, TrendingUp, Plug, Handshake, Key, + Building2, ChevronRight, FileSpreadsheet, + ShieldCheck, Settings, Briefcase, + Building, Bell, Handshake, + PanelLeftClose, PanelLeftOpen, } from "lucide-react"; import { useState, useCallback, useEffect } from "react"; import { hasAnyPermission, PERMISSIONS } from "@/lib/permissions"; +import { MobileBottomNavigation } from "@/components/MobileNavigation"; const allNavItems = [ { path: "/dashboard", label: "Dashboard", icon: LayoutDashboard, perms: [PERMISSIONS.DASHBOARD_VIEW] }, @@ -25,7 +27,7 @@ const allNavItems = [ { path: "/calendar", label: "Calendar", icon: CalendarDays, perms: [PERMISSIONS.CALENDAR_VIEW] }, { path: "/reports", label: "Reports", icon: FileSpreadsheet, perms: [PERMISSIONS.REPORTS_VIEW] }, { path: "/users", label: "Users & Roles", icon: ShieldCheck, perms: [PERMISSIONS.USERS_MANAGE] }, - + { path: "/settings", label: "Settings", icon: Settings, perms: [PERMISSIONS.SETTINGS_MANAGE] }, { path: "/businesses", label: "Businesses", icon: Briefcase, perms: [PERMISSIONS.BUSINESS_MANAGE] }, { path: "/partner", label: "Partner", icon: Handshake, perms: [PERMISSIONS.PARTNER_VIEW] }, @@ -34,11 +36,23 @@ const allNavItems = [ export function Layout({ children }: { children: React.ReactNode }) { const { user, logout } = useAuth(); const location = useLocation(); - const [sidebarOpen, setSidebarOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const [bizOpen, setBizOpen] = useState(false); const [alertOpen, setAlertOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + const saved = localStorage.getItem("finaflow_sidebar_collapsed"); + return saved === "true"; + }); const role = user?.role ?? "viewer"; + const toggleSidebar = () => { + setSidebarCollapsed((prev) => { + const next = !prev; + localStorage.setItem("finaflow_sidebar_collapsed", String(next)); + return next; + }); + }; + const { data: alertList } = trpc.alerts.checkAll.useQuery(undefined, { refetchInterval: 60000 }); const { data: notifCount } = trpc.notifications.unreadCount.useQuery(undefined, { refetchInterval: 30000 }); const { data: notifList } = trpc.notifications.list.useQuery({ limit: 20, unreadOnly: false }); @@ -46,7 +60,6 @@ export function Layout({ children }: { children: React.ReactNode }) { const markNotifRead = trpc.notifications.markRead.useMutation({ onSuccess: () => utils.notifications.invalidate() }); const genOverdueNotifs = trpc.notifications.generateOverdueNotifications.useMutation({ onSuccess: () => utils.notifications.invalidate() }); - // Auto-generate overdue bill notifications on first load useEffect(() => { const timer = setTimeout(() => { genOverdueNotifs.mutate(); }, 3000); return () => clearTimeout(timer); @@ -61,164 +74,224 @@ export function Layout({ children }: { children: React.ReactNode }) { const navItems = allNavItems.filter((item) => hasAnyPermission(role, item.perms)); const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]); + const pinnedSection = ( +
+
+ + {alertOpen && ( +
+
+ +
+ {notifList && notifList.length > 0 && ( +
+ {notifList.slice(0, 10).map(n => ( +
!n.isRead && markNotifRead.mutate({ id: n.id })} className={`cursor-pointer border-b border-[#E8E0D8] px-3 py-2 text-xs ${n.severity === "critical" ? "bg-[#D32F2F]/5 text-[#D32F2F]" : n.severity === "warning" ? "bg-[#ED6C02]/5 text-[#EDA102]" : "text-[#2D2A26]"} ${!n.isRead ? "font-medium" : "opacity-60"}`}> +

{!n.isRead && "● "}{n.title}

+

{n.message}

+
+ ))} +
+ )} + {alertList?.map((alert, i) => ( +
+

{alert.title}

+

{alert.message}

+
+ ))} + {(!notifList || notifList.length === 0) && (!alertList || alertList.length === 0) && ( +

No notifications

+ )} +
+ )} +
+ + {businesses && businesses.length > 1 && ( +
+ + {bizOpen && ( +
+ {businesses.map(b => ( + + ))} +
+ )} +
+ )} + +
+
+ +
+ {!sidebarCollapsed && ( +
+

{user?.name ?? "User"}

+

{role}

+
+ )} +
+ + +
+ ); + return (
- -
-
-
- Finaflow -
- -
- {sidebarOpen && ( -
-
setSidebarOpen(false)} /> -
-
- Menu - -
-
-
+ )} -
-
{children}
+ + {/* Main Content */} +
+
+ {children} +
); -} +} \ No newline at end of file diff --git a/src/components/MobileNavigation.tsx b/src/components/MobileNavigation.tsx new file mode 100644 index 0000000..d2c1f6b --- /dev/null +++ b/src/components/MobileNavigation.tsx @@ -0,0 +1,156 @@ +import { Link, useLocation } from "react-router"; +import { + LayoutDashboard, + Receipt, + TrendingDown, + FileText, + FileSpreadsheet, + CreditCard, + CalendarDays, + Users, + Settings, + Handshake, + Briefcase, + Building, + Building2, + Smartphone, + ShieldCheck, + Bell, + LogOut, + Menu, + X, + ChevronRight, +} from "lucide-react"; +import { useState } from "react"; + +// Primary navigation items - always visible at bottom +export const mobileBottomNavItems = [ + { path: "/dashboard", label: "Dashboard", icon: () => }, + { path: "/daily-sales", label: "Sales", icon: () => }, + { path: "/expenses", label: "Expenses", icon: () => }, + { path: "/bills", label: "Bills", icon: () => }, + { path: "/reports", label: "Reports", icon: () => }, +]; + +// Secondary navigation items - shown in hamburger menu +export const mobileSecondaryNavItems = [ + { path: "/suppliers", label: "Suppliers", icon: Building }, + { path: "/bills", label: "Bills", icon: FileText }, + { path: "/accounts", label: "Accounts", icon: CreditCard }, + { path: "/locations", label: "Branches", icon: Building2 }, + { path: "/payroll", label: "Payroll", icon: Users }, + { path: "/mpesa", label: "M-PESA", icon: Smartphone }, + { path: "/calendar", label: "Calendar", icon: CalendarDays }, + { path: "/reports", label: "Reports", icon: FileSpreadsheet }, + { path: "/users", label: "Users & Roles", icon: ShieldCheck }, + { path: "/settings", label: "Settings", icon: Settings }, + { path: "/businesses", label: "Businesses", icon: Briefcase }, + { path: "/partner", label: "Partner", icon: Handshake }, +]; + +type MobileNavItem = { + path: string; + label: string; + icon: any; +}; + +export function MobileBottomNavigation() { + const location = useLocation(); + + return ( + + ); +} + +export function MobileHamburgerMenu({ + onClose, +}: { + onClose: () => void; +}) { + const location = useLocation(); + + return ( + <> + {/* Backdrop */} +
+ + {/* Menu Panel */} +
+
+ + Menu + + +
+ + + +
+ {/* Business selector */} +
+ + + Business + +
+
+
+ + ); +}