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:
parent
909af53711
commit
21edd5469c
298
src/app/dashboard/contacts/page.tsx
Normal file
298
src/app/dashboard/contacts/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
193
src/app/dashboard/settings/page.tsx
Normal file
193
src/app/dashboard/settings/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
src/app/dashboard/vendors/page.tsx
vendored
163
src/app/dashboard/vendors/page.tsx
vendored
@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
1134
src/app/page.tsx
1134
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
@ -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">
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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:block">
|
||||||
<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 h-full flex-col justify-between rounded-xl border bg-muted/20 p-2">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1 overflow-y-auto">
|
|
||||||
{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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/components/settings/preferences-tab.tsx
Normal file
133
src/components/settings/preferences-tab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/components/settings/team-tab.tsx
Normal file
118
src/components/settings/team-tab.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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."),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}",
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user