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 <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-15 18:07:13 -07:00 committed by GitHub
parent 909af53711
commit 21edd5469c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2443 additions and 870 deletions

View File

@ -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 (
<React.Suspense fallback={<ContactsSkeleton />}>
<ContactsContent />
</React.Suspense>
)
}
function ContactsSkeleton() {
return (
<div className="flex flex-1 flex-col min-h-0 p-4 sm:px-6 md:px-8 pt-3 gap-3">
<div className="flex items-center justify-between shrink-0">
<Skeleton className="h-9 w-52" />
<Skeleton className="h-9 w-32" />
</div>
<Skeleton className="h-9 w-full sm:w-80" />
<Skeleton className="flex-1 rounded-md" />
</div>
)
}
function ContactsContent() {
const searchParams = useSearchParams()
const router = useRouter()
const initialTab = (searchParams.get("tab") as Tab) || "customers"
const [tab, setTab] = React.useState<Tab>(initialTab)
const [loading, setLoading] = React.useState(true)
const [customersList, setCustomersList] = React.useState<Customer[]>([])
const [vendorsList, setVendorsList] = React.useState<Vendor[]>([])
const [customerDialogOpen, setCustomerDialogOpen] = React.useState(false)
const [editingCustomer, setEditingCustomer] =
React.useState<Customer | null>(null)
const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
const [editingVendor, setEditingVendor] =
React.useState<Vendor | null>(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 <ContactsSkeleton />
}
const addLabel = tab === "customers" ? "Add Customer" : "Add Vendor"
const addHandler = tab === "customers" ? openCustomer : openVendor
return (
<>
<div className="flex flex-1 flex-col min-h-0 p-4 sm:px-6 md:px-8 pt-3 gap-3">
{/* single toolbar: tabs left, add button right */}
<Tabs
value={tab}
onValueChange={handleTabChange}
className="flex flex-1 flex-col min-h-0"
>
<div className="flex items-center justify-between gap-3 shrink-0">
<TabsList>
<TabsTrigger value="customers" className="text-xs sm:text-sm">
Customers
<span className="ml-1.5 text-muted-foreground tabular-nums">
{customersList.length}
</span>
</TabsTrigger>
<TabsTrigger value="vendors" className="text-xs sm:text-sm">
Vendors
<span className="ml-1.5 text-muted-foreground tabular-nums">
{vendorsList.length}
</span>
</TabsTrigger>
</TabsList>
<Button onClick={addHandler} size="sm" className="h-8 shrink-0">
<IconPlus className="size-3.5" />
<span className="hidden sm:inline ml-1.5">{addLabel}</span>
</Button>
</div>
<TabsContent
value="customers"
className="mt-3 flex-1 min-h-0 flex flex-col"
>
<CustomersTable
customers={customersList}
onEdit={(customer) => {
setEditingCustomer(customer)
setCustomerDialogOpen(true)
}}
onDelete={handleDeleteCustomer}
/>
</TabsContent>
<TabsContent
value="vendors"
className="mt-3 flex-1 min-h-0 flex flex-col"
>
<VendorsTable
vendors={vendorsList}
onEdit={(vendor) => {
setEditingVendor(vendor)
setVendorDialogOpen(true)
}}
onDelete={handleDeleteVendor}
/>
</TabsContent>
</Tabs>
</div>
<CustomerDialog
open={customerDialogOpen}
onOpenChange={setCustomerDialogOpen}
initialData={editingCustomer}
onSubmit={handleCustomerSubmit}
/>
<VendorDialog
open={vendorDialogOpen}
onOpenChange={setVendorDialogOpen}
initialData={editingVendor}
onSubmit={handleVendorSubmit}
/>
</>
)
}

View File

@ -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<Customer[]>([])
const [loading, setLoading] = React.useState(true)
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editing, setEditing] = React.useState<Customer | null>(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 (
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Customers
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage customer accounts
</p>
</div>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
)
}
return (
<>
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Customers
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage customer accounts
</p>
</div>
<Button onClick={openCreate} className="w-full sm:w-auto">
<IconPlus className="mr-2 size-4" />
Add Customer
</Button>
</div>
{customers.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No customers yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Add your first customer to start tracking contacts and invoices.
</p>
</div>
) : (
<CustomersTable
customers={customers}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</div>
<CustomerDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
initialData={editing}
onSubmit={handleSubmit}
/>
</>
)
export default function CustomersRedirect(): never {
redirect("/dashboard/contacts?tab=customers")
}

View File

@ -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<UserWithRelations[]>([])
const [loading, setLoading] = React.useState(true)
const [selectedUser, setSelectedUser] = React.useState<UserWithRelations | null>(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 (
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">People</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage team members and client users
</p>
</div>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
)
}
router.replace("/dashboard")
}, [router])
return (
<>
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">People</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage team members and client users
</p>
</div>
<Button
onClick={() => setInviteDialogOpen(true)}
className="w-full sm:w-auto"
>
<IconUserPlus className="mr-2 size-4" />
Invite User
</Button>
</div>
{users.length === 0 ? (
<div className="rounded-md border p-8 text-center text-muted-foreground">
<p>No users found</p>
<p className="text-sm mt-2">
Invite users to get started
</p>
</div>
) : (
<PeopleTable
users={users}
onEditUser={handleEditUser}
onDeactivateUser={handleDeactivateUser}
/>
)}
</div>
<UserDrawer
user={selectedUser}
open={drawerOpen}
onOpenChange={setDrawerOpen}
onUserUpdated={handleUserUpdated}
/>
<InviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onUserInvited={handleUserInvited}
/>
</>
<div className="flex-1 flex items-center justify-center p-8">
<p className="text-muted-foreground">
Team management has moved to Settings.
</p>
</div>
)
}

View File

@ -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<string>([
"appearance", "team", "ai-model", "claude-code",
])
function AgentSection() {
const [signetId, setSignetId] = React.useState("")
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="signet-id" className="text-xs">
Signet ID (ETH)
</Label>
<Input
id="signet-id"
value={signetId}
onChange={(e) => setSignetId(e.target.value)}
placeholder="0x..."
className="h-9 max-w-sm font-mono"
type="password"
/>
</div>
<Separator />
<Button className="w-full max-w-sm">
Configure your agent
</Button>
</div>
)
}
function IntegrationsSection() {
return (
<div className="space-y-4">
<GoogleDriveConnectionStatus />
<Separator />
<NetSuiteConnectionStatus />
<SyncControls />
</div>
)
}
export default function SettingsPage() {
const [activeSection, setActiveSection] =
React.useState<SectionValue>("preferences")
const isMobile = useIsMobile()
const isWide = WIDE_SECTIONS.has(activeSection)
const renderContent = (): React.ReactNode => {
switch (activeSection) {
case "preferences":
return <PreferencesTab />
case "appearance":
return <AppearanceTab />
case "team":
return <TeamTab />
case "ai-model":
return <AIModelTab />
case "agent":
return <AgentSection />
case "skills":
return <SkillsTab />
case "integrations":
return <IntegrationsSection />
case "claude-code":
return <ClaudeCodeTab />
default:
return null
}
}
return (
<div className="flex flex-1 flex-col min-h-0">
{/* tab bar — pinned to top, full bleed */}
<div className="shrink-0 border-b bg-background/80 backdrop-blur-sm">
<div className="px-4 sm:px-6 md:px-8">
{isMobile ? (
<div className="py-3">
<Select
value={activeSection}
onValueChange={(v) =>
setActiveSection(v as SectionValue)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SETTINGS_TABS.map((tab) => (
<SelectItem key={tab.value} value={tab.value}>
{tab.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<nav className="flex items-end gap-0.5 -mb-px">
{SETTINGS_TABS.map((tab) => {
const isActive = activeSection === tab.value
return (
<button
key={tab.value}
type="button"
onClick={() => setActiveSection(tab.value)}
className={cn(
"relative flex items-center gap-1.5",
"whitespace-nowrap px-3 py-2.5 text-sm",
"transition-colors duration-100",
isActive
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<tab.icon className="size-4 shrink-0" stroke={1.5} />
{tab.label}
{isActive && (
<span className="absolute inset-x-1 bottom-0 h-0.5 rounded-full bg-primary" />
)}
</button>
)
})}
</nav>
)}
</div>
</div>
{/* scrollable content area */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div
className={cn(
"px-4 sm:px-6 md:px-8 py-6",
!isWide && "max-w-[640px]"
)}
>
{renderContent()}
</div>
</div>
</div>
)
}

View File

@ -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<Vendor[]>([])
const [loading, setLoading] = React.useState(true)
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editing, setEditing] = React.useState<Vendor | null>(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 (
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Vendors
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage vendor relationships
</p>
</div>
</div>
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
</div>
)
}
return (
<>
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Vendors
</h2>
<p className="text-sm sm:text-base text-muted-foreground">
Manage vendor relationships
</p>
</div>
<Button onClick={openCreate} className="w-full sm:w-auto">
<IconPlus className="mr-2 size-4" />
Add Vendor
</Button>
</div>
{vendors.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No vendors yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Add your first vendor to manage subcontractors, suppliers, and bills.
</p>
</div>
) : (
<VendorsTable
vendors={vendors}
onEdit={handleEdit}
onDelete={handleDelete}
/>
)}
</div>
<VendorDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
initialData={editing}
onSubmit={handleSubmit}
/>
</>
)
export default function VendorsRedirect(): never {
redirect("/dashboard/contacts?tab=vendors")
}

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,6 @@ import {
IconMessageCircle,
IconReceipt,
IconSettings,
IconTruck,
IconUsers,
} from "@tabler/icons-react"
import { usePathname } from "next/navigation"
@ -21,7 +19,7 @@ import { NavFiles } from "@/components/nav-files"
import { NavProjects } from "@/components/nav-projects"
import { NavConversations } from "@/components/nav-conversations"
import { NavUser } from "@/components/nav-user"
import { useSettings } from "@/components/settings-provider"
// settings is now a page at /dashboard/settings
import { openFeedbackDialog } from "@/components/feedback-widget"
import type { SidebarUser } from "@/lib/auth"
import {
@ -42,11 +40,6 @@ const data = {
url: "/dashboard/projects",
icon: IconFolder,
},
{
title: "Team",
url: "/dashboard/people",
icon: IconUsers,
},
{
title: "Schedule",
url: "/dashboard/projects/demo-project-1/schedule",
@ -63,15 +56,10 @@ const data = {
icon: IconFiles,
},
{
title: "Customers",
url: "/dashboard/customers",
title: "Contacts",
url: "/dashboard/contacts",
icon: IconAddressBook,
},
{
title: "Vendors",
url: "/dashboard/vendors",
icon: IconTruck,
},
{
title: "Financials",
url: "/dashboard/financials",
@ -81,7 +69,7 @@ const data = {
navSecondary: [
{
title: "Settings",
url: "#",
url: "/dashboard/settings",
icon: IconSettings,
},
],
@ -99,7 +87,6 @@ function SidebarNav({
}) {
const pathname = usePathname()
const { state, setOpen } = useSidebar()
const { open: openSettings } = useSettings()
const isExpanded = state === "expanded"
const isFilesMode = pathname?.startsWith("/dashboard/files")
const isConversationsMode = pathname?.startsWith("/dashboard/conversations")
@ -124,13 +111,7 @@ function SidebarNav({
? "projects"
: "main"
const secondaryItems = [
...data.navSecondary.map((item) =>
item.title === "Settings"
? { ...item, onClick: openSettings }
: item
),
]
const secondaryItems = [...data.navSecondary]
return (
<div key={mode} className="animate-in fade-in slide-in-from-left-1 flex flex-1 flex-col duration-150">

View File

@ -14,10 +14,9 @@ import {
MessageCircle,
LayoutDashboard,
FolderKanban,
Users,
Settings,
FolderOpen,
UserRound,
Building2,
DollarSign,
} from "lucide-react"
@ -49,9 +48,9 @@ const NAV_ITEMS = [
icon: FolderKanban,
},
{
label: "People",
href: "/dashboard/people",
icon: Users,
label: "Settings",
href: "/dashboard/settings",
icon: Settings,
},
{
label: "Files",
@ -59,15 +58,10 @@ const NAV_ITEMS = [
icon: FolderOpen,
},
{
label: "Customers",
href: "/dashboard/customers",
label: "Contacts",
href: "/dashboard/contacts",
icon: UserRound,
},
{
label: "Vendors",
href: "/dashboard/vendors",
icon: Building2,
},
{
label: "Financials",
href: "/dashboard/financials",

View File

@ -85,9 +85,11 @@ export function CustomerDialog({
<Input
id="cust-name"
className="h-9"
placeholder="Contact name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-1.5">
@ -97,43 +99,48 @@ export function CustomerDialog({
<Input
id="cust-company"
className="h-9"
placeholder="Company or organization"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-email" className="text-xs">
Email
</Label>
<Input
id="cust-email"
type="email"
className="h-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-phone" className="text-xs">
Phone
</Label>
<Input
id="cust-phone"
className="h-9"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="cust-email" className="text-xs">
Email
</Label>
<Input
id="cust-email"
type="email"
className="h-9"
placeholder="email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-phone" className="text-xs">
Phone
</Label>
<Input
id="cust-phone"
className="h-9"
placeholder="(555) 123-4567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="cust-address" className="text-xs">
Address
</Label>
<Textarea
<Input
id="cust-address"
className="h-9"
placeholder="Street, city, state"
value={address}
onChange={(e) => setAddress(e.target.value)}
rows={2}
className="text-sm"
/>
</div>
<div className="space-y-1.5">
@ -145,7 +152,8 @@ export function CustomerDialog({
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="text-sm"
placeholder="Additional notes..."
className="text-sm resize-none"
/>
</div>
</ResponsiveDialogBody>

View File

@ -16,7 +16,7 @@ import {
import type { Customer } from "@/db/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
@ -110,49 +110,90 @@ export function CustomersTable({
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "company",
header: "Company",
cell: ({ row }) =>
row.getValue("company") || (
<span className="text-muted-foreground">-</span>
),
cell: ({ row }) => {
const c = row.original
const initials = c.name
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
return (
<div className="flex items-center gap-3">
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<span className="font-medium block truncate">
{c.name}
</span>
{c.company ? (
<span className="text-xs text-muted-foreground block truncate">
{c.company}
</span>
) : null}
</div>
</div>
)
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) =>
row.getValue("email") || (
<span className="text-muted-foreground">-</span>
),
cell: ({ row }) => {
const email = row.getValue("email") as string | null
if (!email) {
return (
<span className="text-muted-foreground/40"></span>
)
}
return (
<a
href={`mailto:${email}`}
className="text-sm hover:underline"
onClick={(e) => e.stopPropagation()}
>
{email}
</a>
)
},
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) =>
row.getValue("phone") || (
<span className="text-muted-foreground">-</span>
),
},
{
accessorKey: "netsuiteId",
header: "NetSuite ID",
cell: ({ row }) => {
const nsId = row.getValue("netsuiteId") as string | null
if (!nsId) return <span className="text-muted-foreground">-</span>
return <Badge variant="outline">{nsId}</Badge>
const phone = row.getValue("phone") as string | null
if (!phone) {
return (
<span className="text-muted-foreground/40"></span>
)
}
return (
<a
href={`tel:${phone}`}
className="text-sm tabular-nums hover:underline"
onClick={(e) => e.stopPropagation()}
>
{phone}
</a>
)
},
},
{
accessorKey: "createdAt",
header: "Created",
header: "Added",
cell: ({ row }) => {
const d = row.getValue("createdAt") as string
return new Date(d).toLocaleDateString()
return (
<span className="text-muted-foreground text-xs tabular-nums">
{new Date(d).toLocaleDateString("en-US", {
month: "short",
year: "numeric",
})}
</span>
)
},
},
{
@ -195,6 +236,7 @@ export function CustomersTable({
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
initialState: { pagination: { pageSize: 100 } },
state: { sorting, columnFilters, rowSelection },
})
@ -241,6 +283,11 @@ export function CustomersTable({
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{c.name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{c.name}
@ -286,19 +333,19 @@ export function CustomersTable({
}
return (
<div className="space-y-4">
<div className="flex flex-1 flex-col min-h-0 gap-3">
<Input
placeholder="Search customers..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
className="h-8 w-full sm:max-w-sm shrink-0"
/>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<div className="rounded-md border flex-1 min-h-0 overflow-hidden">
<div className="overflow-auto h-full">
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
@ -345,30 +392,36 @@ export function CustomersTable({
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
{(table.getPageCount() > 1 ||
table.getFilteredSelectedRowModel().rows.length > 0) && (
<div className="flex items-center justify-between shrink-0">
<div className="text-xs text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length > 0
? `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`
: `${table.getFilteredRowModel().rows.length} contacts`}
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
)
}

View File

@ -16,7 +16,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import type { Vendor } from "@/db/schema"
const VENDOR_CATEGORIES = [
@ -89,68 +88,79 @@ export function VendorDialog({
>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody>
<div className="space-y-1.5">
<Label htmlFor="vendor-name" className="text-xs">
Name *
</Label>
<Input
id="vendor-name"
className="h-9"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<div className="grid grid-cols-5 gap-3">
<div className="col-span-3 space-y-1.5">
<Label htmlFor="vendor-name" className="text-xs">
Name *
</Label>
<Input
id="vendor-name"
className="h-9"
placeholder="Vendor name"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label htmlFor="vendor-category" className="text-xs">
Category *
</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger
id="vendor-category"
className="h-9"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{VENDOR_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-category" className="text-xs">
Category *
</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="vendor-category" className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VENDOR_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-email" className="text-xs">
Email
</Label>
<Input
id="vendor-email"
type="email"
className="h-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-phone" className="text-xs">
Phone
</Label>
<Input
id="vendor-phone"
className="h-9"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="vendor-email" className="text-xs">
Email
</Label>
<Input
id="vendor-email"
type="email"
className="h-9"
placeholder="email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-phone" className="text-xs">
Phone
</Label>
<Input
id="vendor-phone"
className="h-9"
placeholder="(555) 123-4567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="vendor-address" className="text-xs">
Address
</Label>
<Textarea
<Input
id="vendor-address"
className="h-9"
placeholder="Street, city, state"
value={address}
onChange={(e) => setAddress(e.target.value)}
rows={2}
className="text-sm"
/>
</div>
</ResponsiveDialogBody>

View File

@ -16,6 +16,7 @@ import {
import type { Vendor } from "@/db/schema"
import { useIsMobile } from "@/hooks/use-mobile"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@ -61,6 +62,7 @@ export function VendorsTable({
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility] = React.useState({ category: false })
const sortKey = React.useMemo(() => {
if (!sorting.length) return "name-asc"
@ -110,9 +112,35 @@ export function VendorsTable({
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
cell: ({ row }) => {
const v = row.original
const initials = v.name
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
return (
<div className="flex items-center gap-3">
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex items-center gap-2">
<span className="font-medium truncate">
{v.name}
</span>
<Badge
variant="secondary"
className="shrink-0 text-[10px] px-1.5 py-0"
>
{v.category}
</Badge>
</div>
</div>
)
},
},
{
accessorKey: "category",
@ -128,18 +156,44 @@ export function VendorsTable({
{
accessorKey: "email",
header: "Email",
cell: ({ row }) =>
row.getValue("email") || (
<span className="text-muted-foreground">-</span>
),
cell: ({ row }) => {
const email = row.getValue("email") as string | null
if (!email) {
return (
<span className="text-muted-foreground/40"></span>
)
}
return (
<a
href={`mailto:${email}`}
className="text-sm hover:underline"
onClick={(e) => e.stopPropagation()}
>
{email}
</a>
)
},
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) =>
row.getValue("phone") || (
<span className="text-muted-foreground">-</span>
),
cell: ({ row }) => {
const phone = row.getValue("phone") as string | null
if (!phone) {
return (
<span className="text-muted-foreground/40"></span>
)
}
return (
<a
href={`tel:${phone}`}
className="text-sm tabular-nums hover:underline"
onClick={(e) => e.stopPropagation()}
>
{phone}
</a>
)
},
},
{
id: "actions",
@ -181,7 +235,8 @@ export function VendorsTable({
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
initialState: { pagination: { pageSize: 100 } },
state: { sorting, columnFilters, rowSelection, columnVisibility },
})
const emptyState = (
@ -202,7 +257,7 @@ export function VendorsTable({
table.getColumn("category")?.setFilterValue(v === "all" ? "" : v)
}
>
<SelectTrigger className="flex-1 sm:w-[180px] sm:flex-none">
<SelectTrigger className="h-8 flex-1 sm:w-[200px] sm:flex-none">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
@ -277,6 +332,11 @@ export function VendorsTable({
key={row.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{v.name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">
@ -329,22 +389,22 @@ export function VendorsTable({
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col min-h-0 gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center shrink-0">
<Input
placeholder="Search vendors..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
className="w-full sm:max-w-sm"
className="h-8 w-full sm:max-w-sm"
/>
{categoryFilter}
</div>
<div className="rounded-md border overflow-hidden">
<div className="overflow-x-auto">
<div className="rounded-md border flex-1 min-h-0 overflow-hidden">
<div className="overflow-auto h-full">
<Table>
<TableHeader>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
@ -391,30 +451,36 @@ export function VendorsTable({
</Table>
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
{(table.getPageCount() > 1 ||
table.getFilteredSelectedRowModel().rows.length > 0) && (
<div className="flex items-center justify-between shrink-0">
<div className="text-xs text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length > 0
? `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`
: `${table.getFilteredRowModel().rows.length} contacts`}
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
)
}

View File

@ -7,8 +7,8 @@ import {
IconHomeFilled,
IconFolder,
IconFolderFilled,
IconUsers,
IconUsersGroup,
IconSettings,
IconSettingsFilled,
IconFile,
IconFileFilled,
} from "@tabler/icons-react"
@ -99,13 +99,13 @@ export function MobileBottomNav() {
isActive={isActive("/dashboard/projects")}
/>
<NavItem
href="/dashboard/people"
icon={<IconUsers className="size-[22px]" />}
href="/dashboard/settings"
icon={<IconSettings className="size-[22px]" />}
activeIcon={
<IconUsersGroup className="size-[22px]" />
<IconSettingsFilled className="size-[22px]" />
}
label="People"
isActive={isActive("/dashboard/people")}
label="Settings"
isActive={isActive("/dashboard/settings")}
/>
<NavItem
href="/dashboard/files"

View File

@ -194,7 +194,7 @@ export function MobileSearch({
icon: IconUsers,
label: c.name,
sublabel: c.email || c.company || undefined,
href: "/dashboard/customers",
href: "/dashboard/contacts?tab=customers",
category: "customer" as const,
createdAt: c.createdAt,
})),
@ -202,7 +202,7 @@ export function MobileSearch({
icon: IconBuildingStore,
label: v.name,
sublabel: v.category,
href: "/dashboard/vendors",
href: "/dashboard/contacts?tab=vendors",
category: "vendor" as const,
createdAt: v.createdAt,
})),

View File

@ -16,6 +16,7 @@ import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import type { UserWithRelations } from "@/app/actions/users"
@ -52,12 +53,15 @@ interface PeopleTableProps {
users: UserWithRelations[]
onEditUser?: (user: UserWithRelations) => void
onDeactivateUser?: (userId: string) => void
/** Hides select, teams, groups, and projects columns */
compact?: boolean
}
export function PeopleTable({
users,
onEditUser,
onDeactivateUser,
compact = false,
}: PeopleTableProps) {
const isMobile = useIsMobile()
const [sorting, setSorting] = React.useState<SortingState>([])
@ -213,6 +217,10 @@ export function PeopleTable({
},
]
const columnVisibility: VisibilityState = compact
? { select: false, teams: false, groups: false, projects: false }
: {}
const table = useReactTable({
data: users,
columns,
@ -223,16 +231,18 @@ export function PeopleTable({
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
initialState: compact ? { pagination: { pageSize: 100 } } : undefined,
state: {
sorting,
columnFilters,
rowSelection,
columnVisibility,
},
})
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className={compact ? "flex min-h-0 flex-1 flex-col gap-4" : "space-y-4"}>
<div className="flex shrink-0 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Search by name or email..."
value={
@ -317,7 +327,7 @@ export function PeopleTable({
</div>
) : (
<>
<div className="rounded-md border overflow-hidden">
<div className={compact ? "min-h-0 flex-1 rounded-md border overflow-y-auto" : "rounded-md border overflow-hidden"}>
<div className="overflow-x-auto">
<Table>
<TableHeader>
@ -368,11 +378,13 @@ export function PeopleTable({
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
<div className="flex shrink-0 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{!compact && (
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected
</div>
)}
<div className="flex items-center justify-center sm:justify-end space-x-2">
<Button
variant="outline"

View File

@ -33,10 +33,13 @@ import { SkillsTab } from "@/components/settings/skills-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab"
import { AppearanceTab } from "@/components/settings/appearance-tab"
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
import { TeamTab } from "@/components/settings/team-tab"
import { useNative } from "@/hooks/use-native"
import { useBiometricAuth } from "@/hooks/use-biometric-auth"
import { cn } from "@/lib/utils"
const SETTINGS_TABS = [
{ value: "team", label: "Team" },
{ value: "general", label: "General" },
{ value: "notifications", label: "Notifications" },
{ value: "appearance", label: "Theme" },
@ -48,7 +51,7 @@ const SETTINGS_TABS = [
const CREATE_SETTING_TAB = {
value: "create-setting",
label: "Create Setting",
label: "Create",
} as const
interface CustomSettingTab {
@ -151,6 +154,9 @@ export function SettingsModal({
const renderContent = () => {
switch (activeTab) {
case "team":
return <TeamTab />
case "general":
return (
<div className="space-y-4 pt-2">
@ -362,22 +368,31 @@ export function SettingsModal({
}
}
const isWideTab = activeTab === "team"
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[85vh] max-h-[700px] w-full max-w-[600px] overflow-hidden p-0 md:h-auto md:max-h-[85vh] md:max-w-[700px]">
<DialogContent
className={cn(
"w-full p-0 transition-[max-width] duration-200",
"h-[85vh] md:h-[700px]",
isWideTab
? "sm:max-w-[900px]"
: "sm:max-w-[600px] md:max-w-[700px]"
)}
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your app preferences.</DialogDescription>
</DialogHeader>
<div className="flex h-[calc(85vh-80px)] flex-col overflow-hidden md:h-[500px]">
{/* Desktop: 2 columns | Mobile: Single column */}
<div className="flex flex-1 flex-col gap-6 overflow-hidden p-6 md:grid md:grid-cols-[180px_1fr]">
{/* Left Column - Navigation (Desktop only) */}
<aside className="hidden md:flex md:flex-col md:overflow-hidden">
<div className="flex h-full flex-col justify-between rounded-xl border bg-muted/20 p-2">
<div className="flex flex-col gap-1 overflow-y-auto">
{/* Fixed layout: sidebar + content, no modal-level scroll */}
<div className="grid h-[calc(100%-73px)] grid-cols-1 gap-6 p-6 md:grid-cols-[180px_1fr]">
{/* Left Column - Navigation (Desktop only) */}
<aside className="hidden md:block">
<div className="flex h-full flex-col justify-between rounded-xl border bg-muted/20 p-2">
<div className="flex flex-col gap-1">
{menuTabs.map((tab) => (
<Button
key={tab.value}
@ -398,50 +413,38 @@ export function SettingsModal({
className="h-8 w-full justify-start gap-1.5 text-sm"
onClick={openCreateSettingFlow}
>
<IconPlus className="size-4" />
{CREATE_SETTING_TAB.label}
<IconPlus className="size-4 shrink-0" />
<span className="truncate">{CREATE_SETTING_TAB.label}</span>
</Button>
</div>
</div>
</aside>
{/* Middle Column - Content */}
<div className="flex min-h-0 flex-col overflow-hidden">
{/* Mobile Navigation */}
<div className="mb-4 md:hidden">
<Select value={activeTab} onValueChange={handleSectionSelect}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Select section" />
</SelectTrigger>
<SelectContent>
{menuTabs.map((tab) => (
<SelectItem key={tab.value} value={tab.value}>
{tab.label}
</SelectItem>
))}
<SelectItem value={CREATE_SETTING_TAB.value}>
{CREATE_SETTING_TAB.label}
{/* Right Column - Content */}
<div className="flex min-h-0 flex-col">
{/* Mobile Navigation */}
<div className="mb-4 md:hidden">
<Select value={activeTab} onValueChange={handleSectionSelect}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Select section" />
</SelectTrigger>
<SelectContent>
{menuTabs.map((tab) => (
<SelectItem key={tab.value} value={tab.value}>
{tab.label}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Settings Content - Scrollable */}
<div className="flex-1 overflow-y-auto pr-2">
{renderContent()}
</div>
))}
<SelectItem value={CREATE_SETTING_TAB.value}>
{CREATE_SETTING_TAB.label}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Right Column - AI Chat (Desktop only) - COMMENTED OUT */}
{/*
<div className="hidden overflow-hidden rounded-xl border bg-background/95 shadow-lg backdrop-blur-sm md:block">
<ChatView
variant="panel"
hideSuggestions
inputPlaceholder="Create a new setting"
/>
{/* Settings Content - flex so children can fill height */}
<div className="flex min-h-0 flex-1 flex-col">
{renderContent()}
</div>
*/}
</div>
</div>

View File

@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { SettingsModal } from "@/components/settings-modal"
import { useRouter } from "next/navigation"
const SettingsContext = React.createContext<{
open: () => void
@ -11,22 +11,26 @@ export function useSettings() {
return React.useContext(SettingsContext)
}
/**
* Settings is now a full page at /dashboard/settings.
* This provider keeps the useSettings().open() API working
* for any callers that still use it (command palette, etc).
*/
export function SettingsProvider({
children,
}: {
children: React.ReactNode
}) {
const [isOpen, setIsOpen] = React.useState(false)
const router = useRouter()
const value = React.useMemo(
() => ({ open: () => setIsOpen(true) }),
[]
() => ({ open: () => router.push("/dashboard/settings") }),
[router]
)
return (
<SettingsContext.Provider value={value}>
{children}
<SettingsModal open={isOpen} onOpenChange={setIsOpen} />
</SettingsContext.Provider>
)
}

View File

@ -2,74 +2,137 @@
import * as React from "react"
import { useTheme } from "next-themes"
import { Check, Sparkles, Trash2 } from "lucide-react"
import { Check, Moon, Sparkles, Sun, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { useCompassTheme } from "@/components/theme-provider"
import { useAgentOptional } from "@/components/agent/chat-provider"
import { THEME_PRESETS } from "@/lib/theme/presets"
import { deleteCustomTheme } from "@/app/actions/themes"
import type { ThemeDefinition } from "@/lib/theme/types"
import type { ThemeDefinition, ColorMap } from "@/lib/theme/types"
/**
* Mini UI preview showing what the theme actually looks like.
* Renders a tiny mockup: sidebar strip, background, primary accent,
* foreground text lines.
*/
function ThemePreview({
colors,
}: {
readonly colors: ColorMap
}) {
return (
<div
className="relative h-12 w-full overflow-hidden rounded-md"
style={{ backgroundColor: colors.background }}
>
{/* sidebar strip */}
<div
className="absolute inset-y-0 left-0 w-5"
style={{ backgroundColor: colors.sidebar }}
>
{/* sidebar dots */}
<div className="mt-2 flex flex-col items-center gap-0.5">
<div
className="size-1 rounded-full"
style={{
backgroundColor: colors["sidebar-foreground"],
opacity: 0.5,
}}
/>
<div
className="size-1 rounded-full"
style={{
backgroundColor: colors["sidebar-accent"],
}}
/>
<div
className="size-1 rounded-full"
style={{
backgroundColor: colors["sidebar-foreground"],
opacity: 0.5,
}}
/>
</div>
</div>
{/* main content area */}
<div className="ml-5 p-1.5">
{/* primary accent bar */}
<div
className="mb-1 h-1 w-6 rounded-full"
style={{ backgroundColor: colors.primary }}
/>
{/* text lines */}
<div
className="mb-0.5 h-0.5 w-10 rounded-full"
style={{
backgroundColor: colors.foreground,
opacity: 0.5,
}}
/>
<div
className="mb-0.5 h-0.5 w-7 rounded-full"
style={{
backgroundColor: colors.foreground,
opacity: 0.25,
}}
/>
</div>
</div>
)
}
function ThemeCard({
theme,
isActive,
isDark,
onSelect,
onDelete,
}: {
readonly theme: ThemeDefinition
readonly isActive: boolean
readonly isDark: boolean
readonly onSelect: (e: React.MouseEvent) => void
readonly onDelete?: () => void
}) {
const colors = isDark ? theme.dark : theme.light
return (
<button
type="button"
onClick={onSelect}
className={
"relative flex flex-col gap-1.5 rounded-lg border p-3 " +
"text-left transition-colors hover:bg-accent/50 " +
(isActive
className={cn(
"group relative flex flex-col overflow-hidden",
"rounded-lg border text-left transition-all duration-150",
"hover:border-muted-foreground/30",
isActive
? "border-primary ring-1 ring-primary"
: "border-border")
}
>
{isActive && (
<div className="absolute top-2 right-2 rounded-full bg-primary p-0.5">
<Check className="h-3 w-3 text-primary-foreground" />
</div>
: "border-border"
)}
>
<ThemePreview colors={colors} />
<div className="flex gap-1">
{[
theme.previewColors.primary,
theme.previewColors.background,
theme.previewColors.foreground,
].map((color, i) => (
<div
key={i}
className="h-5 w-5 rounded-full border border-border/50"
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
<div className="flex items-center gap-1.5 px-2 py-1.5">
{isActive && (
<div className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary">
<Check className="size-2.5 text-primary-foreground" />
</div>
)}
<span
className={cn(
"truncate text-xs font-medium",
isActive ? "text-primary" : "text-foreground"
)}
>
{theme.name}
</span>
{!theme.isPreset && (
<Badge variant="secondary" className="text-[10px] px-1 py-0">
<Badge
variant="secondary"
className="ml-auto shrink-0 text-[10px] px-1 py-0"
>
Custom
</Badge>
)}
@ -82,13 +145,15 @@ function ThemeCard({
e.stopPropagation()
onDelete()
}}
className={
"absolute bottom-2 right-2 rounded p-1 " +
"text-muted-foreground hover:text-destructive " +
"hover:bg-destructive/10 transition-colors"
}
className={cn(
"absolute top-1.5 right-1.5 rounded-md p-1",
"bg-background/80 backdrop-blur-sm",
"text-muted-foreground hover:text-destructive",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-100"
)}
>
<Trash2 className="h-3 w-3" />
<Trash2 className="size-3" />
</button>
)}
</button>
@ -96,7 +161,7 @@ function ThemeCard({
}
export function AppearanceTab() {
const { theme, setTheme } = useTheme()
const { resolvedTheme, setTheme } = useTheme()
const {
activeThemeId,
setVisualTheme,
@ -105,6 +170,8 @@ export function AppearanceTab() {
} = useCompassTheme()
const panel = useAgentOptional()
const isDark = resolvedTheme === "dark"
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
() => [...THEME_PRESETS, ...customThemes],
[customThemes],
@ -143,35 +210,52 @@ export function AppearanceTab() {
}
return (
<>
<div className="space-y-1.5">
<Label htmlFor="color-mode" className="text-xs">
Color mode
</Label>
<Select
value={theme ?? "light"}
onValueChange={setTheme}
>
<SelectTrigger id="color-mode" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
<div className="space-y-6">
{/* color mode toggle */}
<div className="space-y-2">
<p className="text-sm font-medium">Color mode</p>
<div className="inline-flex rounded-lg border p-0.5">
<button
type="button"
onClick={() => setTheme("light")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5",
"text-sm transition-colors duration-100",
!isDark
? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Sun className="size-3.5" />
Light
</button>
<button
type="button"
onClick={() => setTheme("dark")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5",
"text-sm transition-colors duration-100",
isDark
? "bg-secondary font-medium text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Moon className="size-3.5" />
Dark
</button>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-xs">Theme</Label>
<div className="grid grid-cols-3 gap-2">
{/* theme grid */}
<div className="space-y-3">
<p className="text-sm font-medium">Theme</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{allThemes.map((t) => (
<ThemeCard
key={t.id}
theme={t}
isActive={activeThemeId === t.id}
isDark={isDark}
onSelect={(e) => handleSelectTheme(t.id, e)}
onDelete={
t.isPreset
@ -180,20 +264,26 @@ export function AppearanceTab() {
}
/>
))}
{/* create with AI card */}
<button
type="button"
onClick={handleCreateWithAI}
className={cn(
"flex flex-col items-center justify-center gap-1.5",
"rounded-lg border border-dashed",
"py-4 text-muted-foreground",
"transition-colors duration-100",
"hover:border-primary/50 hover:text-foreground"
)}
>
<Sparkles className="size-4" />
<span className="text-xs font-medium">
Create with AI
</span>
</button>
</div>
</div>
<Separator />
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleCreateWithAI}
>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
Create with AI
</Button>
</>
</div>
)
}

View File

@ -0,0 +1,133 @@
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useNative } from "@/hooks/use-native"
import { useBiometricAuth } from "@/hooks/use-biometric-auth"
export function PreferencesTab() {
const [emailNotifs, setEmailNotifs] = React.useState(true)
const [pushNotifs, setPushNotifs] = React.useState(true)
const [weeklyDigest, setWeeklyDigest] = React.useState(false)
const [timezone, setTimezone] = React.useState("America/New_York")
const native = useNative()
const biometric = useBiometricAuth()
return (
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium">General</h3>
<div className="mt-3 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="timezone" className="text-xs">
Timezone
</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone" className="h-9 w-full max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="America/New_York">
Eastern (ET)
</SelectItem>
<SelectItem value="America/Chicago">
Central (CT)
</SelectItem>
<SelectItem value="America/Denver">
Mountain (MT)
</SelectItem>
<SelectItem value="America/Los_Angeles">
Pacific (PT)
</SelectItem>
<SelectItem value="Europe/London">
London (GMT)
</SelectItem>
<SelectItem value="Europe/Berlin">
Berlin (CET)
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Weekly digest</Label>
<p className="text-muted-foreground text-xs">
Receive a summary of activity each week.
</p>
</div>
<Switch
checked={weeklyDigest}
onCheckedChange={setWeeklyDigest}
className="shrink-0"
/>
</div>
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-medium">Notifications</h3>
<div className="mt-3 space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Email notifications</Label>
<p className="text-muted-foreground text-xs">
Get notified about project updates via email.
</p>
</div>
<Switch
checked={emailNotifs}
onCheckedChange={setEmailNotifs}
className="shrink-0"
/>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Push notifications</Label>
<p className="text-muted-foreground text-xs">
{native
? "Receive push notifications on your device."
: "Receive push notifications in your browser."}
</p>
</div>
<Switch
checked={pushNotifs}
onCheckedChange={setPushNotifs}
className="shrink-0"
/>
</div>
{native && biometric.isAvailable && (
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Biometric lock</Label>
<p className="text-muted-foreground text-xs">
Require Face ID or fingerprint when returning
to the app.
</p>
</div>
<Switch
checked={biometric.isEnabled}
onCheckedChange={biometric.setEnabled}
className="shrink-0"
/>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { IconUserPlus } from "@tabler/icons-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"
export function TeamTab() {
const [users, setUsers] = React.useState<UserWithRelations[]>([])
const [loading, setLoading] = React.useState(true)
const [selectedUser, setSelectedUser] = React.useState<UserWithRelations | null>(null)
const [drawerOpen, setDrawerOpen] = React.useState(false)
const [inviteDialogOpen, setInviteDialogOpen] = React.useState(false)
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 (
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
)
}
return (
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Manage team members and client users
</p>
<Button
onClick={() => setInviteDialogOpen(true)}
size="sm"
>
<IconUserPlus className="mr-2 size-4" />
Invite User
</Button>
</div>
{users.length === 0 ? (
<div className="rounded-md border p-8 text-center text-muted-foreground">
<p>No users found</p>
<p className="text-sm mt-2">
Invite users to get started
</p>
</div>
) : (
<PeopleTable
users={users}
onEditUser={handleEditUser}
onDeactivateUser={handleDeactivateUser}
/>
)}
</div>
<UserDrawer
user={selectedUser}
open={drawerOpen}
onOpenChange={setDrawerOpen}
onUserUpdated={handleUserUpdated}
/>
<InviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onUserInvited={handleUserInvited}
/>
</>
)
}

View File

@ -65,7 +65,7 @@ const TOOL_REGISTRY: ReadonlyArray<ToolMeta> = [
"Valid paths: /dashboard, /dashboard/projects, " +
"/dashboard/projects/{id}, " +
"/dashboard/projects/{id}/schedule, " +
"/dashboard/customers, /dashboard/vendors, " +
"/dashboard/contacts, " +
"/dashboard/financials, /dashboard/people, " +
"/dashboard/files, /dashboard/boards/{id}. " +
"If the page doesn't exist, " +
@ -359,11 +359,9 @@ function buildFirstInteraction(
? "They're on a projects page — lead with project-specific help."
: page.includes("financial")
? "They're on financials — lead with invoice and billing capabilities."
: page.includes("customer")
? "They're on customers — lead with customer lookup and management."
: page.includes("vendor")
? "They're on vendors — lead with vendor and bill capabilities."
: "If they're on the dashboard, offer a broad overview."),
: page.includes("contact")
? "They're on contacts — lead with customer and vendor management."
: "If they're on the dashboard, offer a broad overview."),
]
}

View File

@ -54,6 +54,7 @@ type QueryDataInput = z.infer<typeof queryDataInputSchema>
const VALID_ROUTES: ReadonlyArray<RegExp> = [
/^\/dashboard$/,
/^\/dashboard\/contacts$/,
/^\/dashboard\/customers$/,
/^\/dashboard\/vendors$/,
/^\/dashboard\/projects$/,
@ -279,7 +280,7 @@ export const agentTools = {
"Valid: /dashboard, /dashboard/projects, " +
"/dashboard/projects/{id}, " +
"/dashboard/projects/{id}/schedule, " +
"/dashboard/customers, /dashboard/vendors, " +
"/dashboard/contacts, " +
"/dashboard/financials, /dashboard/people, " +
"/dashboard/files, /dashboard/boards/{id}",
}