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" export default function CustomersRedirect(): never {
import { IconPlus } from "@tabler/icons-react" redirect("/dashboard/contacts?tab=customers")
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}
/>
</>
)
} }

View File

@ -1,148 +1,20 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { IconUserPlus } from "@tabler/icons-react" import { useRouter } from "next/navigation"
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"
export default function PeoplePage() { export default function PeoplePage() {
const [users, setUsers] = React.useState<UserWithRelations[]>([]) const router = useRouter()
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)
React.useEffect(() => { React.useEffect(() => {
loadUsers() router.replace("/dashboard")
}, []) }, [router])
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>
)
}
return ( return (
<> <div className="flex-1 flex items-center justify-center p-8">
<div className="flex-1 space-y-4 p-4 sm:p-6 md:p-8 pt-6"> <p className="text-muted-foreground">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> Team management has moved to Settings.
<div> </p>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">People</h2> </div>
<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}
/>
</>
) )
} }

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" export default function VendorsRedirect(): never {
import { IconPlus } from "@tabler/icons-react" redirect("/dashboard/contacts?tab=vendors")
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}
/>
</>
)
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { SettingsModal } from "@/components/settings-modal" import { useRouter } from "next/navigation"
const SettingsContext = React.createContext<{ const SettingsContext = React.createContext<{
open: () => void open: () => void
@ -11,22 +11,26 @@ export function useSettings() {
return React.useContext(SettingsContext) 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({ export function SettingsProvider({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const [isOpen, setIsOpen] = React.useState(false) const router = useRouter()
const value = React.useMemo( const value = React.useMemo(
() => ({ open: () => setIsOpen(true) }), () => ({ open: () => router.push("/dashboard/settings") }),
[] [router]
) )
return ( return (
<SettingsContext.Provider value={value}> <SettingsContext.Provider value={value}>
{children} {children}
<SettingsModal open={isOpen} onOpenChange={setIsOpen} />
</SettingsContext.Provider> </SettingsContext.Provider>
) )
} }

View File

@ -2,74 +2,137 @@
import * as React from "react" import * as React from "react"
import { useTheme } from "next-themes" 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 { toast } from "sonner"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useCompassTheme } from "@/components/theme-provider" import { useCompassTheme } from "@/components/theme-provider"
import { useAgentOptional } from "@/components/agent/chat-provider" import { useAgentOptional } from "@/components/agent/chat-provider"
import { THEME_PRESETS } from "@/lib/theme/presets" import { THEME_PRESETS } from "@/lib/theme/presets"
import { deleteCustomTheme } from "@/app/actions/themes" 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({ function ThemeCard({
theme, theme,
isActive, isActive,
isDark,
onSelect, onSelect,
onDelete, onDelete,
}: { }: {
readonly theme: ThemeDefinition readonly theme: ThemeDefinition
readonly isActive: boolean readonly isActive: boolean
readonly isDark: boolean
readonly onSelect: (e: React.MouseEvent) => void readonly onSelect: (e: React.MouseEvent) => void
readonly onDelete?: () => void readonly onDelete?: () => void
}) { }) {
const colors = isDark ? theme.dark : theme.light
return ( return (
<button <button
type="button" type="button"
onClick={onSelect} onClick={onSelect}
className={ className={cn(
"relative flex flex-col gap-1.5 rounded-lg border p-3 " + "group relative flex flex-col overflow-hidden",
"text-left transition-colors hover:bg-accent/50 " + "rounded-lg border text-left transition-all duration-150",
(isActive "hover:border-muted-foreground/30",
isActive
? "border-primary ring-1 ring-primary" ? "border-primary ring-1 ring-primary"
: "border-border") : "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>
)} )}
>
<ThemePreview colors={colors} />
<div className="flex gap-1"> <div className="flex items-center gap-1.5 px-2 py-1.5">
{[ {isActive && (
theme.previewColors.primary, <div className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary">
theme.previewColors.background, <Check className="size-2.5 text-primary-foreground" />
theme.previewColors.foreground, </div>
].map((color, i) => ( )}
<div <span
key={i} className={cn(
className="h-5 w-5 rounded-full border border-border/50" "truncate text-xs font-medium",
style={{ backgroundColor: color }} isActive ? "text-primary" : "text-foreground"
/> )}
))} >
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
{theme.name} {theme.name}
</span> </span>
{!theme.isPreset && ( {!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 Custom
</Badge> </Badge>
)} )}
@ -82,13 +145,15 @@ function ThemeCard({
e.stopPropagation() e.stopPropagation()
onDelete() onDelete()
}} }}
className={ className={cn(
"absolute bottom-2 right-2 rounded p-1 " + "absolute top-1.5 right-1.5 rounded-md p-1",
"text-muted-foreground hover:text-destructive " + "bg-background/80 backdrop-blur-sm",
"hover:bg-destructive/10 transition-colors" "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>
)} )}
</button> </button>
@ -96,7 +161,7 @@ function ThemeCard({
} }
export function AppearanceTab() { export function AppearanceTab() {
const { theme, setTheme } = useTheme() const { resolvedTheme, setTheme } = useTheme()
const { const {
activeThemeId, activeThemeId,
setVisualTheme, setVisualTheme,
@ -105,6 +170,8 @@ export function AppearanceTab() {
} = useCompassTheme() } = useCompassTheme()
const panel = useAgentOptional() const panel = useAgentOptional()
const isDark = resolvedTheme === "dark"
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>( const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
() => [...THEME_PRESETS, ...customThemes], () => [...THEME_PRESETS, ...customThemes],
[customThemes], [customThemes],
@ -143,35 +210,52 @@ export function AppearanceTab() {
} }
return ( return (
<> <div className="space-y-6">
<div className="space-y-1.5"> {/* color mode toggle */}
<Label htmlFor="color-mode" className="text-xs"> <div className="space-y-2">
Color mode <p className="text-sm font-medium">Color mode</p>
</Label> <div className="inline-flex rounded-lg border p-0.5">
<Select <button
value={theme ?? "light"} type="button"
onValueChange={setTheme} onClick={() => setTheme("light")}
> className={cn(
<SelectTrigger id="color-mode" className="w-full h-9"> "flex items-center gap-1.5 rounded-md px-3 py-1.5",
<SelectValue /> "text-sm transition-colors duration-100",
</SelectTrigger> !isDark
<SelectContent> ? "bg-secondary font-medium text-foreground"
<SelectItem value="light">Light</SelectItem> : "text-muted-foreground hover:text-foreground"
<SelectItem value="dark">Dark</SelectItem> )}
</SelectContent> >
</Select> <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> </div>
<Separator /> {/* theme grid */}
<div className="space-y-3">
<div className="space-y-2"> <p className="text-sm font-medium">Theme</p>
<Label className="text-xs">Theme</Label> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-3 gap-2">
{allThemes.map((t) => ( {allThemes.map((t) => (
<ThemeCard <ThemeCard
key={t.id} key={t.id}
theme={t} theme={t}
isActive={activeThemeId === t.id} isActive={activeThemeId === t.id}
isDark={isDark}
onSelect={(e) => handleSelectTheme(t.id, e)} onSelect={(e) => handleSelectTheme(t.id, e)}
onDelete={ onDelete={
t.isPreset 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>
</div> </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>
</>
) )
} }

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