From 21edd5469c56662f5ae831bee5321941c2cdc7cb Mon Sep 17 00:00:00 2001 From: Nicholai Date: Sun, 15 Feb 2026 18:07:13 -0700 Subject: [PATCH] feat(ui): redesign landing page and polish dashboard (#86) Dark cartographic landing page with compass rose animation, feature cards, modules ticker, and gantt showcase section. Unified contacts page, settings page routing, sidebar updates, and VisibilityState type fix in people table. Co-authored-by: Nicholai --- src/app/dashboard/contacts/page.tsx | 298 +++++ src/app/dashboard/customers/page.tsx | 164 +-- src/app/dashboard/people/page.tsx | 146 +-- src/app/dashboard/settings/page.tsx | 193 +++ src/app/dashboard/vendors/page.tsx | 163 +-- src/app/page.tsx | 1134 ++++++++++++++++- src/components/app-sidebar.tsx | 29 +- src/components/dashboard-context-menu.tsx | 18 +- src/components/financials/customer-dialog.tsx | 60 +- src/components/financials/customers-table.tsx | 167 ++- src/components/financials/vendor-dialog.tsx | 118 +- src/components/financials/vendors-table.tsx | 150 ++- src/components/mobile-bottom-nav.tsx | 14 +- src/components/mobile-search.tsx | 4 +- src/components/people-table.tsx | 28 +- src/components/settings-modal.tsx | 95 +- src/components/settings-provider.tsx | 14 +- src/components/settings/appearance-tab.tsx | 254 ++-- src/components/settings/preferences-tab.tsx | 133 ++ src/components/settings/team-tab.tsx | 118 ++ src/lib/agent/system-prompt.ts | 10 +- src/lib/agent/tools.ts | 3 +- 22 files changed, 2443 insertions(+), 870 deletions(-) create mode 100644 src/app/dashboard/contacts/page.tsx create mode 100644 src/app/dashboard/settings/page.tsx create mode 100644 src/components/settings/preferences-tab.tsx create mode 100644 src/components/settings/team-tab.tsx diff --git a/src/app/dashboard/contacts/page.tsx b/src/app/dashboard/contacts/page.tsx new file mode 100644 index 0000000..155d76c --- /dev/null +++ b/src/app/dashboard/contacts/page.tsx @@ -0,0 +1,298 @@ +"use client" + +import * as React from "react" +import { IconPlus } from "@tabler/icons-react" +import { Plus } from "lucide-react" +import { useSearchParams, useRouter } from "next/navigation" +import { toast } from "sonner" +import { useRegisterPageActions } from "@/hooks/use-register-page-actions" + +import { + getCustomers, + createCustomer, + updateCustomer, + deleteCustomer, +} from "@/app/actions/customers" +import { + getVendors, + createVendor, + updateVendor, + deleteVendor, +} from "@/app/actions/vendors" +import type { Customer, Vendor } from "@/db/schema" +import { Button } from "@/components/ui/button" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import { Skeleton } from "@/components/ui/skeleton" +import { CustomersTable } from "@/components/financials/customers-table" +import { CustomerDialog } from "@/components/financials/customer-dialog" +import { VendorsTable } from "@/components/financials/vendors-table" +import { VendorDialog } from "@/components/financials/vendor-dialog" + +type Tab = "customers" | "vendors" + +export default function ContactsPage() { + return ( + }> + + + ) +} + +function ContactsSkeleton() { + return ( +
+
+ + +
+ + +
+ ) +} + +function ContactsContent() { + const searchParams = useSearchParams() + const router = useRouter() + const initialTab = (searchParams.get("tab") as Tab) || "customers" + + const [tab, setTab] = React.useState(initialTab) + const [loading, setLoading] = React.useState(true) + + const [customersList, setCustomersList] = React.useState([]) + const [vendorsList, setVendorsList] = React.useState([]) + + const [customerDialogOpen, setCustomerDialogOpen] = React.useState(false) + const [editingCustomer, setEditingCustomer] = + React.useState(null) + + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + const [editingVendor, setEditingVendor] = + React.useState(null) + + const loadAll = async () => { + try { + const [customers, vendors] = await Promise.all([ + getCustomers(), + getVendors(), + ]) + setCustomersList(customers) + setVendorsList(vendors) + } catch { + toast.error("Failed to load contacts") + } finally { + setLoading(false) + } + } + + React.useEffect(() => { + loadAll() + }, []) + + const openCustomer = React.useCallback(() => { + setEditingCustomer(null) + setCustomerDialogOpen(true) + }, []) + + const openVendor = React.useCallback(() => { + setEditingVendor(null) + setVendorDialogOpen(true) + }, []) + + const TAB_ACTIONS: Record< + Tab, + { id: string; label: string; onSelect: () => void } + > = React.useMemo( + () => ({ + customers: { + id: "add-customer", + label: "Add Customer", + onSelect: openCustomer, + }, + vendors: { + id: "add-vendor", + label: "Add Vendor", + onSelect: openVendor, + }, + }), + [openCustomer, openVendor] + ) + + const pageActions = React.useMemo(() => { + const action = TAB_ACTIONS[tab] + return [{ ...action, icon: Plus }] + }, [tab, TAB_ACTIONS]) + + useRegisterPageActions(pageActions) + + const handleTabChange = (value: string) => { + setTab(value as Tab) + router.replace(`/dashboard/contacts?tab=${value}`, { scroll: false }) + } + + const handleCustomerSubmit = async (data: { + name: string + company: string + email: string + phone: string + address: string + notes: string + }) => { + if (editingCustomer) { + const result = await updateCustomer(editingCustomer.id, data) + if (result.success) { + toast.success("Customer updated") + } else { + toast.error(result.error || "Failed") + return + } + } else { + const result = await createCustomer(data) + if (result.success) { + toast.success("Customer created") + } else { + toast.error(result.error || "Failed") + return + } + } + setCustomerDialogOpen(false) + await loadAll() + } + + const handleDeleteCustomer = async (id: string) => { + const result = await deleteCustomer(id) + if (result.success) { + toast.success("Customer deleted") + await loadAll() + } else { + toast.error(result.error || "Failed") + } + } + + const handleVendorSubmit = async (data: { + name: string + category: string + email: string + phone: string + address: string + }) => { + if (editingVendor) { + const result = await updateVendor(editingVendor.id, data) + if (result.success) { + toast.success("Vendor updated") + } else { + toast.error(result.error || "Failed") + return + } + } else { + const result = await createVendor(data) + if (result.success) { + toast.success("Vendor created") + } else { + toast.error(result.error || "Failed") + return + } + } + setVendorDialogOpen(false) + await loadAll() + } + + const handleDeleteVendor = async (id: string) => { + const result = await deleteVendor(id) + if (result.success) { + toast.success("Vendor deleted") + await loadAll() + } else { + toast.error(result.error || "Failed") + } + } + + if (loading) { + return + } + + const addLabel = tab === "customers" ? "Add Customer" : "Add Vendor" + const addHandler = tab === "customers" ? openCustomer : openVendor + + return ( + <> +
+ {/* single toolbar: tabs left, add button right */} + +
+ + + Customers + + {customersList.length} + + + + Vendors + + {vendorsList.length} + + + + + +
+ + + { + setEditingCustomer(customer) + setCustomerDialogOpen(true) + }} + onDelete={handleDeleteCustomer} + /> + + + + { + setEditingVendor(vendor) + setVendorDialogOpen(true) + }} + onDelete={handleDeleteVendor} + /> + +
+
+ + + + + + ) +} diff --git a/src/app/dashboard/customers/page.tsx b/src/app/dashboard/customers/page.tsx index 4e5ca17..18c4067 100755 --- a/src/app/dashboard/customers/page.tsx +++ b/src/app/dashboard/customers/page.tsx @@ -1,163 +1,5 @@ -"use client" +import { redirect } from "next/navigation" -import * as React from "react" -import { IconPlus } from "@tabler/icons-react" -import { Plus } from "lucide-react" -import { toast } from "sonner" - -import { - getCustomers, - createCustomer, - updateCustomer, - deleteCustomer, -} from "@/app/actions/customers" -import type { Customer } from "@/db/schema" -import { Button } from "@/components/ui/button" -import { CustomersTable } from "@/components/financials/customers-table" -import { CustomerDialog } from "@/components/financials/customer-dialog" -import { useRegisterPageActions } from "@/hooks/use-register-page-actions" - -export default function CustomersPage() { - const [customers, setCustomers] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [dialogOpen, setDialogOpen] = React.useState(false) - const [editing, setEditing] = React.useState(null) - - const openCreate = React.useCallback(() => { - setEditing(null) - setDialogOpen(true) - }, []) - - const pageActions = React.useMemo( - () => [ - { - id: "add-customer", - label: "Add Customer", - icon: Plus, - onSelect: openCreate, - }, - ], - [openCreate] - ) - useRegisterPageActions(pageActions) - - const load = async () => { - try { - const data = await getCustomers() - setCustomers(data) - } catch { - toast.error("Failed to load customers") - } finally { - setLoading(false) - } - } - - React.useEffect(() => { load() }, []) - - const handleEdit = (customer: Customer) => { - setEditing(customer) - setDialogOpen(true) - } - - const handleDelete = async (id: string) => { - const result = await deleteCustomer(id) - if (result.success) { - toast.success("Customer deleted") - await load() - } else { - toast.error(result.error || "Failed to delete customer") - } - } - - const handleSubmit = async (data: { - name: string - company: string - email: string - phone: string - address: string - notes: string - }) => { - if (editing) { - const result = await updateCustomer(editing.id, data) - if (result.success) { - toast.success("Customer updated") - } else { - toast.error(result.error || "Failed to update customer") - return - } - } else { - const result = await createCustomer(data) - if (result.success) { - toast.success("Customer created") - } else { - toast.error(result.error || "Failed to create customer") - return - } - } - setDialogOpen(false) - await load() - } - - if (loading) { - return ( -
-
-
-

- Customers -

-

- Manage customer accounts -

-
-
-
- Loading... -
-
- ) - } - - return ( - <> -
-
-
-

- Customers -

-

- Manage customer accounts -

-
- -
- - {customers.length === 0 ? ( -
-

No customers yet

-

- Add your first customer to start tracking contacts and invoices. -

-
- ) : ( - - )} -
- - - - ) +export default function CustomersRedirect(): never { + redirect("/dashboard/contacts?tab=customers") } diff --git a/src/app/dashboard/people/page.tsx b/src/app/dashboard/people/page.tsx index 0deb0bf..8a9d038 100755 --- a/src/app/dashboard/people/page.tsx +++ b/src/app/dashboard/people/page.tsx @@ -1,148 +1,20 @@ "use client" import * as React from "react" -import { IconUserPlus } from "@tabler/icons-react" -import { UserPlus } from "lucide-react" -import { toast } from "sonner" - -import { getUsers, deactivateUser, type UserWithRelations } from "@/app/actions/users" -import { Button } from "@/components/ui/button" -import { PeopleTable } from "@/components/people-table" -import { UserDrawer } from "@/components/people/user-drawer" -import { InviteDialog } from "@/components/people/invite-dialog" -import { useRegisterPageActions } from "@/hooks/use-register-page-actions" - +import { useRouter } from "next/navigation" export default function PeoplePage() { - const [users, setUsers] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [selectedUser, setSelectedUser] = React.useState(null) - const [drawerOpen, setDrawerOpen] = React.useState(false) - const [inviteDialogOpen, setInviteDialogOpen] = React.useState(false) - - const pageActions = React.useMemo( - () => [ - { - id: "invite-user", - label: "Invite User", - icon: UserPlus, - onSelect: () => setInviteDialogOpen(true), - }, - ], - [] - ) - useRegisterPageActions(pageActions) + const router = useRouter() React.useEffect(() => { - loadUsers() - }, []) - - const loadUsers = async () => { - try { - const data = await getUsers() - setUsers(data) - } catch (error) { - console.error("Failed to load users:", error) - toast.error("Failed to load users") - } finally { - setLoading(false) - } - } - - const handleEditUser = (user: UserWithRelations) => { - setSelectedUser(user) - setDrawerOpen(true) - } - - const handleDeactivateUser = async (userId: string) => { - try { - const result = await deactivateUser(userId) - if (result.success) { - toast.success("User deactivated") - await loadUsers() - } else { - toast.error(result.error || "Failed to deactivate user") - } - } catch (error) { - console.error("Failed to deactivate user:", error) - toast.error("Failed to deactivate user") - } - } - - const handleUserUpdated = async () => { - await loadUsers() - } - - const handleUserInvited = async () => { - await loadUsers() - } - - if (loading) { - return ( -
-
-
-

People

-

- Manage team members and client users -

-
-
-
- Loading... -
-
- ) - } + router.replace("/dashboard") + }, [router]) return ( - <> -
-
-
-

People

-

- Manage team members and client users -

-
- -
- - {users.length === 0 ? ( -
-

No users found

-

- Invite users to get started -

-
- ) : ( - - )} -
- - - - - - +
+

+ Team management has moved to Settings. +

+
) } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..01e46f0 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -0,0 +1,193 @@ +"use client" + +import * as React from "react" +import { + IconAdjustments, + IconBrain, + IconPalette, + IconPlug, + IconPuzzle, + IconRobot, + IconTerminal2, + IconUsers, +} from "@tabler/icons-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" + +import { PreferencesTab } from "@/components/settings/preferences-tab" +import { AppearanceTab } from "@/components/settings/appearance-tab" +import { TeamTab } from "@/components/settings/team-tab" +import { AIModelTab } from "@/components/settings/ai-model-tab" +import { SkillsTab } from "@/components/settings/skills-tab" +import { ClaudeCodeTab } from "@/components/settings/claude-code-tab" +import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status" +import { SyncControls } from "@/components/netsuite/sync-controls" +import { GoogleDriveConnectionStatus } from "@/components/google/connection-status" + +const SETTINGS_TABS = [ + { value: "preferences", label: "Preferences", icon: IconAdjustments }, + { value: "appearance", label: "Theme", icon: IconPalette }, + { value: "team", label: "Team", icon: IconUsers }, + { value: "ai-model", label: "AI Model", icon: IconBrain }, + { value: "agent", label: "Agent", icon: IconRobot }, + { value: "skills", label: "Skills", icon: IconPuzzle }, + { value: "integrations", label: "Integrations", icon: IconPlug }, + { value: "claude-code", label: "Code Bridge", icon: IconTerminal2 }, +] as const + +type SectionValue = (typeof SETTINGS_TABS)[number]["value"] + +// wide sections get unconstrained width for tables/complex layouts +const WIDE_SECTIONS = new Set([ + "appearance", "team", "ai-model", "claude-code", +]) + +function AgentSection() { + const [signetId, setSignetId] = React.useState("") + + return ( +
+
+ + setSignetId(e.target.value)} + placeholder="0x..." + className="h-9 max-w-sm font-mono" + type="password" + /> +
+ + +
+ ) +} + +function IntegrationsSection() { + return ( +
+ + + + +
+ ) +} + +export default function SettingsPage() { + const [activeSection, setActiveSection] = + React.useState("preferences") + const isMobile = useIsMobile() + + const isWide = WIDE_SECTIONS.has(activeSection) + + const renderContent = (): React.ReactNode => { + switch (activeSection) { + case "preferences": + return + case "appearance": + return + case "team": + return + case "ai-model": + return + case "agent": + return + case "skills": + return + case "integrations": + return + case "claude-code": + return + default: + return null + } + } + + return ( +
+ {/* tab bar — pinned to top, full bleed */} +
+
+ {isMobile ? ( +
+ +
+ ) : ( + + )} +
+
+ + {/* scrollable content area */} +
+
+ {renderContent()} +
+
+
+ ) +} diff --git a/src/app/dashboard/vendors/page.tsx b/src/app/dashboard/vendors/page.tsx index 344ceb1..a2f6245 100755 --- a/src/app/dashboard/vendors/page.tsx +++ b/src/app/dashboard/vendors/page.tsx @@ -1,162 +1,5 @@ -"use client" +import { redirect } from "next/navigation" -import * as React from "react" -import { IconPlus } from "@tabler/icons-react" -import { Plus } from "lucide-react" -import { toast } from "sonner" - -import { - getVendors, - createVendor, - updateVendor, - deleteVendor, -} from "@/app/actions/vendors" -import type { Vendor } from "@/db/schema" -import { Button } from "@/components/ui/button" -import { VendorsTable } from "@/components/financials/vendors-table" -import { VendorDialog } from "@/components/financials/vendor-dialog" -import { useRegisterPageActions } from "@/hooks/use-register-page-actions" - -export default function VendorsPage() { - const [vendors, setVendors] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [dialogOpen, setDialogOpen] = React.useState(false) - const [editing, setEditing] = React.useState(null) - - const openCreate = React.useCallback(() => { - setEditing(null) - setDialogOpen(true) - }, []) - - const pageActions = React.useMemo( - () => [ - { - id: "add-vendor", - label: "Add Vendor", - icon: Plus, - onSelect: openCreate, - }, - ], - [openCreate] - ) - useRegisterPageActions(pageActions) - - const load = async () => { - try { - const data = await getVendors() - setVendors(data) - } catch { - toast.error("Failed to load vendors") - } finally { - setLoading(false) - } - } - - React.useEffect(() => { load() }, []) - - const handleEdit = (vendor: Vendor) => { - setEditing(vendor) - setDialogOpen(true) - } - - const handleDelete = async (id: string) => { - const result = await deleteVendor(id) - if (result.success) { - toast.success("Vendor deleted") - await load() - } else { - toast.error(result.error || "Failed to delete vendor") - } - } - - const handleSubmit = async (data: { - name: string - category: string - email: string - phone: string - address: string - }) => { - if (editing) { - const result = await updateVendor(editing.id, data) - if (result.success) { - toast.success("Vendor updated") - } else { - toast.error(result.error || "Failed to update vendor") - return - } - } else { - const result = await createVendor(data) - if (result.success) { - toast.success("Vendor created") - } else { - toast.error(result.error || "Failed to create vendor") - return - } - } - setDialogOpen(false) - await load() - } - - if (loading) { - return ( -
-
-
-

- Vendors -

-

- Manage vendor relationships -

-
-
-
- Loading... -
-
- ) - } - - return ( - <> -
-
-
-

- Vendors -

-

- Manage vendor relationships -

-
- -
- - {vendors.length === 0 ? ( -
-

No vendors yet

-

- Add your first vendor to manage subcontractors, suppliers, and bills. -

-
- ) : ( - - )} -
- - - - ) +export default function VendorsRedirect(): never { + redirect("/dashboard/contacts?tab=vendors") } diff --git a/src/app/page.tsx b/src/app/page.tsx index b1fa401..6cc7471 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,155 @@ import Link from "next/link"; -import { IconArrowRight, IconBrandGithub } from "@tabler/icons-react"; +import { + IconArrowRight, + IconBrandGithub, + IconBrandDocker, + IconBrandApple, + IconBrandWindows, + IconBrandAndroid, + IconDeviceDesktop, + IconCloud, + IconSparkles, + IconPuzzle, + IconRocket, + IconShieldLock, +} from "@tabler/icons-react"; -export default function Home() { +const features = [ + { + num: "01", + title: "AI Agent Built In", + description: + "Every workspace ships with an intelligent assistant " + + "that understands your domain and takes action " + + "through tools you define.", + icon: IconSparkles, + }, + { + num: "02", + title: "Modular by Design", + description: + "Scheduling, financials, file management, " + + "messaging \u2014 drop in what you need, leave " + + "out what you don\u2019t.", + icon: IconPuzzle, + }, + { + num: "03", + title: "Deploy Anywhere", + description: + "Self-host with Docker, ship to desktop and " + + "mobile, or deploy to the edge with Cloudflare. " + + "Your infrastructure, your call.", + icon: IconRocket, + }, + { + num: "04", + title: "Enterprise Auth", + description: + "SSO, directory sync, and role-based access " + + "control out of the box via WorkOS.", + icon: IconShieldLock, + }, +] as const; + +const platforms = [ + { icon: IconBrandDocker, label: "Docker" }, + { icon: IconBrandApple, label: "macOS" }, + { icon: IconBrandWindows, label: "Windows" }, + { icon: IconDeviceDesktop, label: "Linux" }, + { icon: IconBrandApple, label: "iOS" }, + { icon: IconBrandAndroid, label: "Android" }, + { icon: IconCloud, label: "Edge" }, +] as const; + +const modules = [ + "Scheduling", + "Financials", + "File Management", + "Messaging", + "NetSuite Sync", + "Google Drive", + "Themes", + "Plugins", +] as const; + +export default function Home(): React.JSX.Element { return ( -
-