feat(ui): add mobile support and dashboard improvements (#30)

* feat(schema): add auth, people, and financial tables

Add users, organizations, teams, groups, and project
members tables. Extend customers/vendors with netsuite
fields. Add netsuite schema for invoices, bills,
payments, and credit memos. Include all migrations,
seeds, new UI primitives, and config updates.

* feat(auth): add WorkOS authentication system

Add login, signup, password reset, email verification,
and invitation flows via WorkOS AuthKit. Includes auth
middleware, permission helpers, dev mode fallbacks,
and auth page components.

* feat(people): add people management system

Add user, team, group, and organization management
with CRUD actions, dashboard pages, invite dialog,
user drawer, and role-based filtering. Includes
WorkOS invitation integration.

* feat(netsuite): add NetSuite integration and financials

Add bidirectional NetSuite REST API integration with
OAuth 2.0, rate limiting, sync engine, and conflict
resolution. Includes invoices, vendor bills, payments,
credit memos CRUD, customer/vendor management pages,
and financial dashboard with tabbed views.

* feat(ui): add mobile support and dashboard improvements

Add mobile bottom nav, FAB, filter bar, search, project
switcher, pull-to-refresh, and schedule mobile view.
Update sidebar with new nav items, settings modal with
integrations tab, responsive dialogs, improved schedule
and file components, PWA manifest, and service worker.

* ci: retrigger build

* ci: retrigger build

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-04 16:39:39 -07:00 committed by GitHub
parent fbd31b58ae
commit d30decf723
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 3044 additions and 1065 deletions

24
public/manifest.json Executable file
View File

@ -0,0 +1,24 @@
{
"name": "Compass Dashboard",
"short_name": "Compass",
"description": "Construction project management by High Performance Structures",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/apple-touch-icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

34
public/sw.js Executable file
View File

@ -0,0 +1,34 @@
// basic service worker for PWA support
const CACHE_NAME = 'compass-v1';
const urlsToCache = [
'/',
'/dashboard',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});

View File

@ -1,5 +1,6 @@
import { AppSidebar } from "@/components/app-sidebar" import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import { MobileBottomNav } from "@/components/mobile-bottom-nav"
import { CommandMenuProvider } from "@/components/command-menu-provider" import { CommandMenuProvider } from "@/components/command-menu-provider"
import { SettingsProvider } from "@/components/settings-provider" import { SettingsProvider } from "@/components/settings-provider"
import { FeedbackWidget } from "@/components/feedback-widget" import { FeedbackWidget } from "@/components/feedback-widget"
@ -9,6 +10,7 @@ import {
SidebarProvider, SidebarProvider,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { getProjects } from "@/app/actions/projects" import { getProjects } from "@/app/actions/projects"
import { ProjectListProvider } from "@/components/project-list-provider"
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
@ -19,6 +21,7 @@ export default async function DashboardLayout({
return ( return (
<SettingsProvider> <SettingsProvider>
<ProjectListProvider projects={projectList}>
<CommandMenuProvider> <CommandMenuProvider>
<SidebarProvider <SidebarProvider
defaultOpen={false} defaultOpen={false}
@ -33,19 +36,21 @@ export default async function DashboardLayout({
<FeedbackWidget> <FeedbackWidget>
<SidebarInset className="overflow-hidden"> <SidebarInset className="overflow-hidden">
<SiteHeader /> <SiteHeader />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden pb-14 md:pb-0">
<div className="@container/main flex flex-1 flex-col"> <div className="@container/main flex flex-1 flex-col min-w-0">
{children} {children}
</div> </div>
</div> </div>
</SidebarInset> </SidebarInset>
</FeedbackWidget> </FeedbackWidget>
<p className="pointer-events-none fixed bottom-3 left-0 right-0 text-center text-xs text-muted-foreground/60"> <MobileBottomNav />
<p className="pointer-events-none fixed bottom-3 left-0 right-0 hidden text-center text-xs text-muted-foreground/60 md:block">
Pre-alpha build Pre-alpha build
</p> </p>
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</SidebarProvider> </SidebarProvider>
</CommandMenuProvider> </CommandMenuProvider>
</ProjectListProvider>
</SettingsProvider> </SettingsProvider>
) )
} }

View File

@ -84,9 +84,9 @@ export default async function Page() {
const data = await getRepoData() const data = await getRepoData()
return ( return (
<div className="flex flex-1 items-start justify-center p-4 sm:p-6 md:p-12"> <div className="flex flex-1 items-start justify-center p-3 sm:p-6 md:p-12 overflow-x-hidden min-w-0">
<div className="w-full max-w-6xl py-4 sm:py-8"> <div className="w-full max-w-6xl py-4 sm:py-8 min-w-0">
<div className="mb-10 text-center"> <div className="mb-6 sm:mb-10 text-center">
<span <span
className="mx-auto mb-3 block size-12 bg-foreground" className="mx-auto mb-3 block size-12 bg-foreground"
style={{ style={{
@ -98,10 +98,10 @@ export default async function Page() {
WebkitMaskRepeat: "no-repeat", WebkitMaskRepeat: "no-repeat",
}} }}
/> />
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
Compass Compass
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2 text-sm sm:text-base px-2">
Development preview features may be incomplete Development preview features may be incomplete
or change without notice. or change without notice.
</p> </p>
@ -110,85 +110,85 @@ export default async function Page() {
</div> </div>
</div> </div>
<div className="grid gap-10 lg:grid-cols-2"> <div className="grid gap-6 sm:gap-10 lg:grid-cols-2 min-w-0">
<div className="space-y-8 text-sm leading-relaxed"> <div className="space-y-6 sm:space-y-8 text-sm leading-relaxed min-w-0">
<section> <section>
<h2 className="mb-3 text-base font-semibold flex items-center gap-2"> <h2 className="mb-2 sm:mb-3 text-sm sm:text-base font-semibold flex items-center gap-2">
<span className="inline-block size-2 rounded-full bg-green-500" /> <span className="inline-block size-2 rounded-full bg-green-500" />
Working Working
</h2> </h2>
<ul className="space-y-1.5 pl-4"> <ul className="space-y-1.5 pl-4 break-words">
<li>Projects create and manage projects with D1 database</li> <li className="break-words">Projects create and manage projects with D1 database</li>
<li>Schedule Gantt chart with phases, tasks, dependencies, and critical path</li> <li className="break-words">Schedule Gantt chart with phases, tasks, dependencies, and critical path</li>
<li>File browser drive-style UI with folder navigation</li> <li className="break-words">File browser drive-style UI with folder navigation</li>
<li>Settings app preferences with theme and notifications</li> <li className="break-words">Settings app preferences with theme and notifications</li>
<li>Sidebar navigation with contextual project/file views</li> <li className="break-words">Sidebar navigation with contextual project/file views</li>
<li>Command palette search (Cmd+K)</li> <li className="break-words">Command palette search (Cmd+K)</li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-semibold flex items-center gap-2"> <h2 className="mb-2 sm:mb-3 text-sm sm:text-base font-semibold flex items-center gap-2">
<span className="inline-block size-2 rounded-full bg-yellow-500" /> <span className="inline-block size-2 rounded-full bg-yellow-500" />
In Progress In Progress
</h2> </h2>
<ul className="space-y-1.5 pl-4"> <ul className="space-y-1.5 pl-4 break-words">
<li>Project auto-provisioning (code generation, CSI folder structure)</li> <li className="break-words">Project auto-provisioning (code generation, CSI folder structure)</li>
<li>Budget tracking (CSI divisions, estimated vs actual, change orders)</li> <li className="break-words">Budget tracking (CSI divisions, estimated vs actual, change orders)</li>
<li>Document management (S3/R2 storage, metadata, versioning)</li> <li className="break-words">Document management (S3/R2 storage, metadata, versioning)</li>
<li>Communication logging (manual entries, timeline view)</li> <li className="break-words">Communication logging (manual entries, timeline view)</li>
<li>Dashboard three-column layout (past due, due today, action items)</li> <li className="break-words">Dashboard three-column layout (past due, due today, action items)</li>
<li>User authentication and roles (WorkOS)</li> <li className="break-words">User authentication and roles (WorkOS)</li>
<li>Email notifications (Resend)</li> <li className="break-words">Email notifications (Resend)</li>
<li>Basic reports (budget variance, overdue tasks, monthly actuals)</li> <li className="break-words">Basic reports (budget variance, overdue tasks, monthly actuals)</li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-semibold flex items-center gap-2"> <h2 className="mb-2 sm:mb-3 text-sm sm:text-base font-semibold flex items-center gap-2">
<span className="inline-block size-2 rounded-full bg-muted-foreground/50" /> <span className="inline-block size-2 rounded-full bg-muted-foreground/50" />
Planned Planned
</h2> </h2>
<ul className="space-y-1.5 pl-4 text-muted-foreground"> <ul className="space-y-1.5 pl-4 text-muted-foreground break-words">
<li>Client portal with read-only views</li> <li className="break-words">Client portal with read-only views</li>
<li>BuilderTrend import wizard (CSV-based)</li> <li className="break-words">BuilderTrend import wizard (CSV-based)</li>
<li>Daily logs</li> <li className="break-words">Daily logs</li>
<li>Time tracking</li> <li className="break-words">Time tracking</li>
<li>Report builder (custom fields and filters)</li> <li className="break-words">Report builder (custom fields and filters)</li>
<li>Bid package management</li> <li className="break-words">Bid package management</li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 className="mb-3 text-base font-semibold flex items-center gap-2"> <h2 className="mb-2 sm:mb-3 text-sm sm:text-base font-semibold flex items-center gap-2">
<span className="inline-block size-2 rounded-full bg-muted-foreground/30" /> <span className="inline-block size-2 rounded-full bg-muted-foreground/30" />
Future Future
</h2> </h2>
<ul className="space-y-1.5 pl-4 text-muted-foreground"> <ul className="space-y-1.5 pl-4 text-muted-foreground break-words">
<li>Netsuite/QuickBooks API sync</li> <li className="break-words">Netsuite/QuickBooks API sync</li>
<li>Payment integration</li> <li className="break-words">Payment integration</li>
<li>RFI/Submittal tracking</li> <li className="break-words">RFI/Submittal tracking</li>
<li>Native mobile apps (iOS/Android)</li> <li className="break-words">Native mobile apps (iOS/Android)</li>
<li>Advanced scheduling (resource leveling, baseline comparison)</li> <li className="break-words">Advanced scheduling (resource leveling, baseline comparison)</li>
</ul> </ul>
</section> </section>
</div> </div>
{data && ( {data && (
<div className="lg:sticky lg:top-6 lg:self-start space-y-6"> <div className="lg:sticky lg:top-6 lg:self-start space-y-4 sm:space-y-6 min-w-0">
<a <a
href={GITHUB_URL} href={GITHUB_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:bg-muted/50 border rounded-lg px-4 py-3 flex items-center gap-3 transition-colors" className="hover:bg-muted/50 border rounded-lg px-3 sm:px-4 py-3 flex items-center gap-3 transition-colors"
> >
<IconBrandGithub className="size-5 shrink-0" /> <IconBrandGithub className="size-5 shrink-0" />
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium">View on GitHub</p> <p className="text-sm font-medium">View on GitHub</p>
<p className="text-muted-foreground text-xs truncate">{REPO}</p> <p className="text-muted-foreground text-xs truncate">{REPO}</p>
</div> </div>
<IconExternalLink className="text-muted-foreground size-3.5 shrink-0 ml-auto" /> <IconExternalLink className="text-muted-foreground size-3.5 shrink-0" />
</a> </a>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<StatCard <StatCard
@ -214,7 +214,7 @@ export default async function Page() {
</div> </div>
<div> <div>
<h2 className="text-muted-foreground mb-3 text-xs font-medium uppercase tracking-wider"> <h2 className="text-muted-foreground mb-2 sm:mb-3 text-xs font-medium uppercase tracking-wider">
Recent Commits Recent Commits
</h2> </h2>
<div className="border rounded-lg divide-y"> <div className="border rounded-lg divide-y">
@ -224,20 +224,20 @@ export default async function Page() {
href={commit.html_url} href={commit.html_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:bg-muted/50 flex items-start gap-3 px-4 py-3 transition-colors" className="hover:bg-muted/50 flex items-start gap-2 sm:gap-3 px-3 sm:px-4 py-2 sm:py-3 transition-colors"
> >
<IconGitCommit className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <IconGitCommit className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm"> <p className="truncate text-sm break-words">
{commit.commit.message.split("\n")[0]} {commit.commit.message.split("\n")[0]}
</p> </p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs truncate">
{commit.commit.author.name} {commit.commit.author.name}
<span className="mx-1.5">·</span> <span className="mx-1.5">·</span>
{timeAgo(commit.commit.author.date)} {timeAgo(commit.commit.author.date)}
</p> </p>
</div> </div>
<code className="text-muted-foreground shrink-0 font-mono text-xs"> <code className="text-muted-foreground shrink-0 font-mono text-xs hidden sm:inline">
{commit.sha.slice(0, 7)} {commit.sha.slice(0, 7)}
</code> </code>
</a> </a>
@ -262,12 +262,12 @@ function StatCard({
value: number value: number
}) { }) {
return ( return (
<div className="border rounded-lg px-4 py-3"> <div className="border rounded-lg px-3 sm:px-4 py-2 sm:py-3">
<div className="text-muted-foreground mb-1 flex items-center gap-1.5 text-xs"> <div className="text-muted-foreground mb-1 flex items-center gap-1.5 text-xs">
{icon} {icon}
{label} {label}
</div> </div>
<p className="text-2xl font-semibold tabular-nums"> <p className="text-xl sm:text-2xl font-semibold tabular-nums">
{value.toLocaleString()} {value.toLocaleString()}
</p> </p>
</div> </div>

View File

@ -4,6 +4,7 @@ import { projects, scheduleTasks } from "@/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { MobileProjectSwitcher } from "@/components/mobile-project-switcher"
import { import {
IconAlertTriangle, IconAlertTriangle,
IconCalendarStats, IconCalendarStats,
@ -11,9 +12,7 @@ import {
IconClock, IconClock,
IconDots, IconDots,
IconFlag, IconFlag,
IconPlus,
IconThumbUp, IconThumbUp,
IconUser,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import type { ScheduleTask } from "@/db/schema" import type { ScheduleTask } from "@/db/schema"
@ -135,81 +134,44 @@ export default async function ProjectSummaryPage({
<div className="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 md:p-6"> <div className="flex-1 overflow-y-auto p-4 md:p-6">
{/* header */} {/* header */}
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-1">
<div> <MobileProjectSwitcher
<div className="flex items-center gap-3 mb-1"> projectName={projectName}
<h1 className="text-2xl font-semibold">{projectName}</h1> projectId={id}
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary"> status={projectStatus}
{projectStatus} />
</span> <button className="p-1.5 rounded-lg hover:bg-accent transition-colors text-muted-foreground shrink-0 mt-0.5">
</div>
{project?.address && (
<p className="text-sm text-muted-foreground">
{project.address}
</p>
)}
<p className="text-sm text-muted-foreground mt-0.5">
{totalCount} tasks &middot; {completedPercent}% complete
</p>
</div>
<button className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground">
<IconDots className="size-5" /> <IconDots className="size-5" />
</button> </button>
</div> </div>
{/* client / pm row */} {/* meta line: address + tasks */}
<div className="flex flex-wrap gap-4 sm:gap-8 mb-6"> <div className="text-sm text-muted-foreground space-y-0.5 mb-3">
<div> {project?.address && <p>{project.address}</p>}
<p className="text-xs font-medium uppercase text-muted-foreground mb-2"> <p>
Client {totalCount} tasks &middot; {completedPercent}% complete
</p> {project?.clientName && (
<div className="flex items-center gap-2"> <> &middot; {project.clientName}</>
{project?.clientName ? ( )}
<> {project?.projectManager && (
<div className="size-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary"> <> &middot; {project.projectManager}</>
{project.clientName.split(" ").map(w => w[0]).join("").slice(0, 2)} )}
</div> </p>
<span className="text-sm">{project.clientName}</span> </div>
</>
) : ( {/* schedule link */}
<button className="size-8 rounded-full border border-dashed flex items-center justify-center text-muted-foreground hover:border-foreground hover:text-foreground transition-colors"> <div className="mb-5 sm:mb-6">
<IconPlus className="size-4" /> <Link
</button> href={`/dashboard/projects/${id}/schedule`}
)} className="text-sm text-primary hover:underline inline-flex items-center gap-1.5"
</div> >
</div> <IconCalendarStats className="size-4" />
<div> View schedule
<p className="text-xs font-medium uppercase text-muted-foreground mb-2"> </Link>
Project Manager
</p>
<div className="flex items-center gap-2">
{project?.projectManager ? (
<>
<div className="size-8 rounded-full bg-accent flex items-center justify-center">
<IconUser className="size-4 text-muted-foreground" />
</div>
<span className="text-sm">{project.projectManager}</span>
</>
) : (
<button className="size-8 rounded-full border border-dashed flex items-center justify-center text-muted-foreground hover:border-foreground hover:text-foreground transition-colors">
<IconPlus className="size-4" />
</button>
)}
</div>
</div>
<div className="sm:ml-auto self-end w-full sm:w-auto">
<Link
href={`/dashboard/projects/${id}/schedule`}
className="text-sm text-primary hover:underline flex items-center gap-1.5"
>
<IconCalendarStats className="size-4" />
View schedule
</Link>
</div>
</div> </div>
{/* progress bar */} {/* progress bar */}
<div className="rounded-lg border p-4 mb-6"> <div className="rounded-lg border p-3 sm:p-4 mb-4 sm:mb-6">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">Overall Progress</p> <p className="text-sm font-medium">Overall Progress</p>
<p className="text-sm font-semibold">{completedPercent}%</p> <p className="text-sm font-semibold">{completedPercent}%</p>
@ -230,8 +192,8 @@ export default async function ProjectSummaryPage({
</div> </div>
{/* urgency columns */} {/* urgency columns */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-px rounded-lg border overflow-hidden mb-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-px rounded-lg border overflow-hidden mb-4 sm:mb-6">
<div className="p-4 bg-background"> <div className="p-3 sm:p-4 bg-background">
<p className="text-xs font-medium uppercase text-muted-foreground mb-3"> <p className="text-xs font-medium uppercase text-muted-foreground mb-3">
Past Due Past Due
</p> </p>
@ -261,7 +223,7 @@ export default async function ProjectSummaryPage({
)} )}
</div> </div>
<div className="p-4 bg-background border-t sm:border-t-0 sm:border-x"> <div className="p-3 sm:p-4 bg-background border-t sm:border-t-0 sm:border-x">
<p className="text-xs font-medium uppercase text-muted-foreground mb-3"> <p className="text-xs font-medium uppercase text-muted-foreground mb-3">
Due Today Due Today
</p> </p>
@ -279,7 +241,7 @@ export default async function ProjectSummaryPage({
)} )}
</div> </div>
<div className="p-4 bg-background border-t sm:border-t-0"> <div className="p-3 sm:p-4 bg-background border-t sm:border-t-0">
<p className="text-xs font-medium uppercase text-muted-foreground mb-3"> <p className="text-xs font-medium uppercase text-muted-foreground mb-3">
Upcoming Milestones Upcoming Milestones
</p> </p>
@ -306,7 +268,7 @@ export default async function ProjectSummaryPage({
</div> </div>
{/* two-column: phases + active tasks */} {/* two-column: phases + active tasks */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6 mb-4 sm:mb-6">
{/* phase breakdown */} {/* phase breakdown */}
<div> <div>
<h2 className="text-xs font-medium uppercase text-muted-foreground mb-3"> <h2 className="text-xs font-medium uppercase text-muted-foreground mb-3">
@ -421,7 +383,7 @@ export default async function ProjectSummaryPage({
</div> </div>
{/* right sidebar: week agenda */} {/* right sidebar: week agenda */}
<div className="w-full lg:w-72 border-t lg:border-t-0 lg:border-l overflow-y-auto p-4 shrink-0"> <div className="w-full lg:w-72 border-t lg:border-t-0 lg:border-l overflow-y-auto p-3 sm:p-4 shrink-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-medium uppercase text-muted-foreground"> <h2 className="text-xs font-medium uppercase text-muted-foreground">
This Week This Week

View File

@ -27,6 +27,12 @@ export const metadata: Metadata = {
icon: "/favicon.png", icon: "/favicon.png",
apple: "/apple-touch-icon.png", apple: "/apple-touch-icon.png",
}, },
manifest: "/manifest.json",
viewport: {
width: "device-width",
initialScale: 1,
maximumScale: 5,
},
}; };
export default function RootLayout({ export default function RootLayout({

View File

@ -32,96 +32,101 @@ export function AccountModal({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="max-h-[90vh] w-[calc(100%-2rem)] max-w-md overflow-y-auto p-4 sm:p-6">
<DialogHeader> <DialogHeader className="space-y-1">
<DialogTitle>Account Settings</DialogTitle> <DialogTitle className="text-base">Account Settings</DialogTitle>
<DialogDescription> <DialogDescription className="text-xs">
Manage your profile and security settings. Manage your profile and security settings.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-2"> <div className="space-y-3 py-1">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<Avatar className="size-16"> <Avatar className="size-12">
<AvatarImage src="/avatars/martine.jpg" alt={name} /> <AvatarImage src="/avatars/martine.jpg" alt={name} />
<AvatarFallback>MV</AvatarFallback> <AvatarFallback className="text-sm">MV</AvatarFallback>
</Avatar> </Avatar>
<button className="bg-primary text-primary-foreground absolute -right-1 -bottom-1 flex size-6 items-center justify-center rounded-full"> <button className="bg-primary text-primary-foreground absolute -right-0.5 -bottom-0.5 flex size-5 items-center justify-center rounded-full">
<IconCamera className="size-3.5" /> <IconCamera className="size-3" />
</button> </button>
</div> </div>
<div> <div className="min-w-0">
<p className="font-medium">{name}</p> <p className="text-sm font-medium truncate">{name}</p>
<p className="text-muted-foreground text-sm">{email}</p> <p className="text-muted-foreground text-xs truncate">{email}</p>
</div> </div>
</div> </div>
<Separator /> <Separator className="my-3" />
<div className="space-y-4"> <div className="space-y-3">
<h4 className="text-sm font-medium">Profile</h4> <h4 className="text-xs font-semibold uppercase text-muted-foreground">Profile</h4>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="name">Name</Label> <Label htmlFor="name" className="text-xs">Name</Label>
<Input <Input
id="name" id="name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="h-9"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="email">Email</Label> <Label htmlFor="email" className="text-xs">Email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="h-9"
/> />
</div> </div>
</div> </div>
<Separator /> <Separator className="my-3" />
<div className="space-y-4"> <div className="space-y-3">
<h4 className="text-sm font-medium">Change Password</h4> <h4 className="text-xs font-semibold uppercase text-muted-foreground">Change Password</h4>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="current-password">Current Password</Label> <Label htmlFor="current-password" className="text-xs">Current Password</Label>
<Input <Input
id="current-password" id="current-password"
type="password" type="password"
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password" placeholder="Enter current password"
className="h-9"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="new-password">New Password</Label> <Label htmlFor="new-password" className="text-xs">New Password</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password" placeholder="Enter new password"
className="h-9"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="confirm-password">Confirm Password</Label> <Label htmlFor="confirm-password" className="text-xs">Confirm Password</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password" placeholder="Confirm new password"
className="h-9"
/> />
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className="gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 sm:flex-initial h-9 text-sm">
Cancel Cancel
</Button> </Button>
<Button onClick={() => onOpenChange(false)}> <Button onClick={() => onOpenChange(false)} className="flex-1 sm:flex-initial h-9 text-sm">
Save Changes Save Changes
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -2,13 +2,17 @@
import * as React from "react" import * as React from "react"
import { import {
IconAddressBook,
IconCalendarStats, IconCalendarStats,
IconDashboard, IconDashboard,
IconFiles, IconFiles,
IconFolder, IconFolder,
IconHelp, IconHelp,
IconReceipt,
IconSearch, IconSearch,
IconSettings, IconSettings,
IconTruck,
IconUsers,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
@ -47,6 +51,11 @@ const data = {
url: "/dashboard/projects", url: "/dashboard/projects",
icon: IconFolder, icon: IconFolder,
}, },
{
title: "People",
url: "/dashboard/people",
icon: IconUsers,
},
{ {
title: "Schedule", title: "Schedule",
url: "/dashboard/projects/demo-project-1/schedule", url: "/dashboard/projects/demo-project-1/schedule",
@ -57,6 +66,21 @@ const data = {
url: "/dashboard/files", url: "/dashboard/files",
icon: IconFiles, icon: IconFiles,
}, },
{
title: "Customers",
url: "/dashboard/customers",
icon: IconAddressBook,
},
{
title: "Vendors",
url: "/dashboard/vendors",
icon: IconTruck,
},
{
title: "Financials",
url: "/dashboard/financials",
icon: IconReceipt,
},
], ],
navSecondary: [ navSecondary: [
{ {
@ -87,11 +111,12 @@ function SidebarNav({
pathname ?? "" pathname ?? ""
) )
React.useEffect(() => { // Allow manual collapse/expand in all modes
if ((isFilesMode || isProjectMode) && !isExpanded) { // React.useEffect(() => {
setOpen(true) // if ((isFilesMode || isProjectMode) && !isExpanded) {
} // setOpen(true)
}, [isFilesMode, isProjectMode, isExpanded, setOpen]) // }
// }, [isFilesMode, isProjectMode, isExpanded, setOpen])
const showContext = isExpanded && (isFilesMode || isProjectMode) const showContext = isExpanded && (isFilesMode || isProjectMode)

View File

@ -2,6 +2,8 @@
import * as React from "react" import * as React from "react"
import { CommandMenu } from "@/components/command-menu" import { CommandMenu } from "@/components/command-menu"
import { MobileSearch } from "@/components/mobile-search"
import { useIsMobile } from "@/hooks/use-mobile"
const CommandMenuContext = React.createContext<{ const CommandMenuContext = React.createContext<{
open: () => void open: () => void
@ -16,17 +18,32 @@ export function CommandMenuProvider({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const isMobile = useIsMobile()
const [isOpen, setIsOpen] = React.useState(false) const [isOpen, setIsOpen] = React.useState(false)
const [mobileSearchOpen, setMobileSearchOpen] =
React.useState(false)
const value = React.useMemo( const value = React.useMemo(
() => ({ open: () => setIsOpen(true) }), () => ({
[] open: () => {
if (isMobile) {
setMobileSearchOpen(true)
} else {
setIsOpen(true)
}
},
}),
[isMobile]
) )
return ( return (
<CommandMenuContext.Provider value={value}> <CommandMenuContext.Provider value={value}>
{children} {children}
<CommandMenu open={isOpen} setOpen={setIsOpen} /> <CommandMenu open={isOpen} setOpen={setIsOpen} />
<MobileSearch
open={mobileSearchOpen}
setOpen={setMobileSearchOpen}
/>
</CommandMenuContext.Provider> </CommandMenuContext.Provider>
) )
} }

View File

@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface Tab {
id: string
label: string
count?: number
}
interface DashboardTabsProps {
tabs: Tab[]
activeTab: string
onTabChange: (tabId: string) => void
className?: string
}
export function DashboardTabs({
tabs,
activeTab,
onTabChange,
className,
}: DashboardTabsProps) {
return (
<div className={cn("border-b", className)}>
<div className="flex overflow-x-auto px-2 md:px-4 [&::-webkit-scrollbar]:hidden">
{tabs.map((tab) => (
<button
key={tab.id}
className={cn(
"relative shrink-0 px-4 py-3 text-sm font-medium transition-colors",
"hover:text-foreground",
activeTab === tab.id
? "text-foreground"
: "text-muted-foreground"
)}
onClick={() => onTabChange(tab.id)}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
)}
</span>
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
</div>
)
}

View File

@ -141,6 +141,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
id: "drag", id: "drag",
header: () => null, header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />, cell: ({ row }) => <DragHandle id={row.original.id} />,
meta: { className: "hidden md:table-cell" },
}, },
{ {
id: "select", id: "select",
@ -180,12 +181,13 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
accessorKey: "type", accessorKey: "type",
header: "Section Type", header: "Section Type",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="w-32"> <div className="min-w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5"> <Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type} {row.original.type}
</Badge> </Badge>
</div> </div>
), ),
meta: { className: "hidden lg:table-cell" },
}, },
{ {
accessorKey: "status", accessorKey: "status",
@ -250,6 +252,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
/> />
</form> </form>
), ),
meta: { className: "hidden lg:table-cell" },
}, },
{ {
accessorKey: "reviewer", accessorKey: "reviewer",
@ -327,11 +330,14 @@ function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
transition: transition, transition: transition,
}} }}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => {
<TableCell key={cell.id}> const meta = cell.column.columnDef.meta as { className?: string } | undefined
{flexRender(cell.column.columnDef.cell, cell.getContext())} return (
</TableCell> <TableCell key={cell.id} className={meta?.className}>
))} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)
})}
</TableRow> </TableRow>
) )
} }
@ -479,56 +485,63 @@ export function DataTable({
value="outline" value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6" className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
> >
<div className="overflow-hidden rounded-lg border"> <div className="overflow-x-auto -mx-4 md:mx-0 rounded-lg border">
<DndContext <div className="inline-block min-w-full align-middle">
collisionDetection={closestCenter} <DndContext
modifiers={[restrictToVerticalAxis]} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}
sensors={sensors} onDragEnd={handleDragEnd}
id={sortableId} sensors={sensors}
> id={sortableId}
<Table> >
<TableHeader className="bg-muted sticky top-0 z-10"> <Table>
{table.getHeaderGroups().map((headerGroup) => ( <TableHeader className="bg-muted sticky top-0 z-10">
<TableRow key={headerGroup.id}> {table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => { <TableRow key={headerGroup.id}>
return ( {headerGroup.headers.map((header) => {
<TableHead key={header.id} colSpan={header.colSpan}> const meta = header.column.columnDef.meta as { className?: string } | undefined
{header.isPlaceholder return (
? null <TableHead
: flexRender( key={header.id}
header.column.columnDef.header, colSpan={header.colSpan}
header.getContext() className={meta?.className}
)} >
</TableHead> {header.isPlaceholder
) ? null
})} : flexRender(
</TableRow> header.column.columnDef.header,
))} header.getContext()
</TableHeader> )}
<TableBody className="**:data-[slot=table-cell]:first:w-8"> </TableHead>
{table.getRowModel().rows?.length ? ( )
<SortableContext })}
items={dataIds} </TableRow>
strategy={verticalListSortingStrategy} ))}
> </TableHeader>
{table.getRowModel().rows.map((row) => ( <TableBody className="**:data-[slot=table-cell]:first:w-8">
<DraggableRow key={row.id} row={row} /> {table.getRowModel().rows?.length ? (
))} <SortableContext
</SortableContext> items={dataIds}
) : ( strategy={verticalListSortingStrategy}
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
> >
No results. {table.getRowModel().rows.map((row) => (
</TableCell> <DraggableRow key={row.id} row={row} />
</TableRow> ))}
)} </SortableContext>
</TableBody> ) : (
</Table> <TableRow>
</DndContext> <TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
</div> </div>
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex"> <div className="text-muted-foreground hidden flex-1 text-sm lg:flex">

View File

@ -101,7 +101,7 @@ export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
<Button <Button
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
size="icon-lg" size="icon-lg"
className="group fixed bottom-12 right-6 z-40 gap-0 rounded-full shadow-lg transition-all duration-200 hover:w-auto hover:gap-2 hover:px-4 overflow-hidden" className="group fixed bottom-12 right-6 z-40 gap-0 rounded-full shadow-lg transition-all duration-200 hover:w-auto hover:gap-2 hover:px-4 overflow-hidden hidden md:flex"
> >
<MessageCircle className="size-5 shrink-0" /> <MessageCircle className="size-5 shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:max-w-40 group-hover:opacity-100"> <span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:max-w-40 group-hover:opacity-100">
@ -110,18 +110,18 @@ export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
</Button> </Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="max-w-[calc(100%-2rem)] sm:max-w-md p-4 sm:p-6">
<DialogHeader> <DialogHeader className="space-y-1.5">
<DialogTitle>Send Feedback</DialogTitle> <DialogTitle className="text-base sm:text-lg">Send Feedback</DialogTitle>
<DialogDescription> <DialogDescription className="text-xs sm:text-sm">
Report a bug, request a feature, or ask a question. Report a bug, request a feature, or ask a question.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4"> <form onSubmit={handleSubmit} className="grid gap-3 sm:gap-4">
<div className="grid gap-2"> <div className="grid gap-1.5">
<Label htmlFor="feedback-type">Type</Label> <Label htmlFor="feedback-type" className="text-xs sm:text-sm">Type</Label>
<Select value={type} onValueChange={setType}> <Select value={type} onValueChange={setType}>
<SelectTrigger id="feedback-type"> <SelectTrigger id="feedback-type" className="h-9">
<SelectValue placeholder="Select type..." /> <SelectValue placeholder="Select type..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -133,45 +133,48 @@ export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
</Select> </Select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-1.5">
<Label htmlFor="feedback-message">Message</Label> <Label htmlFor="feedback-message" className="text-xs sm:text-sm">Message</Label>
<Textarea <Textarea
id="feedback-message" id="feedback-message"
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your feedback..." placeholder="Describe your feedback..."
maxLength={2000} maxLength={2000}
rows={4} rows={3}
required required
className="text-sm resize-none"
/> />
<p className="text-xs text-muted-foreground text-right"> <p className="text-xs text-muted-foreground text-right">
{message.length}/2000 {message.length}/2000
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
<div className="grid gap-2"> <div className="grid gap-1.5">
<Label htmlFor="feedback-name">Name (optional)</Label> <Label htmlFor="feedback-name" className="text-xs sm:text-sm">Name (optional)</Label>
<Input <Input
id="feedback-name" id="feedback-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Your name" placeholder="Your name"
className="h-9 text-sm"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-1.5">
<Label htmlFor="feedback-email">Email (optional)</Label> <Label htmlFor="feedback-email" className="text-xs sm:text-sm">Email (optional)</Label>
<Input <Input
id="feedback-email" id="feedback-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com" placeholder="you@example.com"
className="h-9 text-sm"
/> />
</div> </div>
</div> </div>
<Button type="submit" disabled={submitting || !type || !message.trim()}> <Button type="submit" disabled={submitting || !type || !message.trim()} className="h-9 text-sm">
{submitting ? "Submitting..." : "Submit Feedback"} {submitting ? "Submitting..." : "Submit Feedback"}
</Button> </Button>
</form> </form>

View File

@ -140,6 +140,7 @@ export function FileBrowser({ path }: { path: string[] }) {
onOpenChange={(open) => !open && setMoveFile(null)} onOpenChange={(open) => !open && setMoveFile(null)}
file={moveFile} file={moveFile}
/> />
</div> </div>
) )
} }

View File

@ -32,10 +32,10 @@ export function FileGrid({
const regularFiles = files.filter((f) => f.type !== "folder") const regularFiles = files.filter((f) => f.type !== "folder")
return ( return (
<div className="space-y-6"> <div className="space-y-4 sm:space-y-6">
{folders.length > 0 && ( {folders.length > 0 && (
<section> <section>
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3"> <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 sm:mb-3">
Folders Folders
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
@ -53,10 +53,10 @@ export function FileGrid({
)} )}
{regularFiles.length > 0 && ( {regularFiles.length > 0 && (
<section> <section>
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3"> <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 sm:mb-3">
Files Files
</h3> </h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3"> <div className="grid grid-cols-2 min-[500px]:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2 sm:gap-3">
{regularFiles.map((file) => ( {regularFiles.map((file) => (
<FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}> <FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}>
<FileCard <FileCard

View File

@ -42,7 +42,7 @@ export const FolderCard = forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"group flex items-center gap-3 rounded-xl border bg-card px-4 py-3 cursor-pointer", "group flex items-center gap-3 rounded-xl border bg-card px-3 py-3 cursor-pointer min-h-[60px]",
"hover:shadow-sm hover:border-border/80 transition-all", "hover:shadow-sm hover:border-border/80 transition-all",
selected && "border-primary ring-2 ring-primary/20" selected && "border-primary ring-2 ring-primary/20"
)} )}
@ -50,8 +50,8 @@ export const FolderCard = forwardRef<
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
{...props} {...props}
> >
<FileIcon type="folder" size={22} /> <FileIcon type="folder" size={22} className="shrink-0" />
<span className="text-sm font-medium truncate flex-1">{file.name}</span> <span className="text-sm font-medium line-clamp-2 flex-1 break-words">{file.name}</span>
{file.shared && ( {file.shared && (
<IconUsers size={14} className="text-muted-foreground shrink-0" /> <IconUsers size={14} className="text-muted-foreground shrink-0" />
)} )}
@ -98,25 +98,22 @@ export const FileCard = forwardRef<
> >
<div <div
className={cn( className={cn(
"flex items-center justify-center h-32", "flex items-center justify-center h-20 sm:h-24",
fileTypeColors[file.type] ?? fileTypeColors.unknown fileTypeColors[file.type] ?? fileTypeColors.unknown
)} )}
> >
<FileIcon type={file.type} size={48} className="opacity-70" /> <FileIcon type={file.type} size={32} className="opacity-70 sm:size-10" />
</div> </div>
<div className="flex items-center gap-2.5 px-3 py-2.5 border-t"> <div className="flex flex-col gap-1 px-2.5 py-2.5 border-t">
<FileIcon type={file.type} size={16} /> <p className="text-sm font-medium line-clamp-2 break-words leading-snug">{file.name}</p>
<div className="flex-1 min-w-0"> <div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate">{file.name}</p> <p className="text-xs text-muted-foreground truncate">
<p className="text-xs text-muted-foreground">
{formatRelativeDate(file.modifiedAt)} {formatRelativeDate(file.modifiedAt)}
{file.shared && " · Shared"} {file.shared && " · Shared"}
</p> </p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button <button
className={cn( className={cn(
"opacity-0 group-hover:opacity-100 transition-opacity", "opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0",
file.starred && "opacity-100" file.starred && "opacity-100"
)} )}
onClick={(e) => { onClick={(e) => {
@ -130,12 +127,6 @@ export const FileCard = forwardRef<
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" /> <IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
)} )}
</button> </button>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<IconDots size={16} />
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -36,27 +36,31 @@ export function FileList({
} }
return ( return (
<Table> <div className="overflow-x-auto -mx-2 sm:mx-0">
<TableHeader> <div className="inline-block min-w-full align-middle">
<TableRow> <Table>
<TableHead>Name</TableHead> <TableHeader>
<TableHead>Modified</TableHead> <TableRow>
<TableHead>Owner</TableHead> <TableHead className="min-w-[200px]">Name</TableHead>
<TableHead>Size</TableHead> <TableHead className="hidden sm:table-cell">Modified</TableHead>
<TableHead className="w-8" /> <TableHead className="hidden md:table-cell">Owner</TableHead>
</TableRow> <TableHead className="hidden lg:table-cell">Size</TableHead>
</TableHeader> <TableHead className="w-8" />
<TableBody> </TableRow>
{files.map((file) => ( </TableHeader>
<FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}> <TableBody>
<FileRow {files.map((file) => (
file={file} <FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}>
selected={selectedIds.has(file.id)} <FileRow
onClick={(e) => onItemClick(file.id, e)} file={file}
/> selected={selectedIds.has(file.id)}
</FileContextMenu> onClick={(e) => onItemClick(file.id, e)}
))} />
</TableBody> </FileContextMenu>
</Table> ))}
</TableBody>
</Table>
</div>
</div>
) )
} }

View File

@ -53,13 +53,12 @@ export function FileToolbar({
} }
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-col sm:flex-row gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2 flex-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm" variant="outline"> <Button size="sm" variant="outline" className="h-10 sm:h-9">
<IconPlus size={16} /> <IconPlus size={18} className="sm:size-4" />
<span className="hidden sm:inline">New</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
@ -87,71 +86,68 @@ export function FileToolbar({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<div className="relative flex-1">
<IconSearch
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search files..."
value={state.searchQuery}
onChange={(e) =>
dispatch({ type: "SET_SEARCH", payload: e.target.value })
}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className="h-10 sm:h-9 pl-9 text-sm"
/>
</div>
</div> </div>
<div className="flex-1" /> <div className="flex items-center gap-2">
<DropdownMenu>
<div <DropdownMenuTrigger asChild>
className={`relative transition-all duration-200 ${ <Button size="sm" variant="ghost" className="h-10 sm:h-9">
searchFocused ? "w-48 sm:w-64" : "w-32 sm:w-44" {state.sortDirection === "asc" ? (
}`} <IconSortAscending size={18} className="sm:size-4" />
> ) : (
<IconSearch <IconSortDescending size={18} className="sm:size-4" />
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search files..."
value={state.searchQuery}
onChange={(e) =>
dispatch({ type: "SET_SEARCH", payload: e.target.value })
}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
className="h-8 pl-8 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
{state.sortDirection === "asc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
)}
<span className="hidden sm:inline">{sortLabels[state.sortBy]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(sortLabels) as SortField[]).map((field) => (
<DropdownMenuItem key={field} onClick={() => handleSort(field)}>
{sortLabels[field]}
{state.sortBy === field && (
<span className="ml-auto text-xs text-muted-foreground">
{state.sortDirection === "asc" ? "↑" : "↓"}
</span>
)} )}
</DropdownMenuItem> </Button>
))} </DropdownMenuTrigger>
</DropdownMenuContent> <DropdownMenuContent align="end">
</DropdownMenu> {(Object.keys(sortLabels) as SortField[]).map((field) => (
<DropdownMenuItem key={field} onClick={() => handleSort(field)}>
{sortLabels[field]}
{state.sortBy === field && (
<span className="ml-auto text-xs text-muted-foreground">
{state.sortDirection === "asc" ? "↑" : "↓"}
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ToggleGroup <ToggleGroup
type="single" type="single"
value={state.viewMode} value={state.viewMode}
onValueChange={(v) => { onValueChange={(v) => {
if (v) dispatch({ type: "SET_VIEW_MODE", payload: v as ViewMode }) if (v) dispatch({ type: "SET_VIEW_MODE", payload: v as ViewMode })
}} }}
size="sm" variant="outline"
> size="sm"
<ToggleGroupItem value="grid" aria-label="Grid view"> className="h-10 sm:h-9"
<IconLayoutGrid size={16} /> >
</ToggleGroupItem> <ToggleGroupItem value="grid" aria-label="Grid view" className="h-10 w-10 sm:h-9 sm:w-9">
<ToggleGroupItem value="list" aria-label="List view"> <IconLayoutGrid size={18} className="sm:size-4" />
<IconList size={16} /> </ToggleGroupItem>
</ToggleGroupItem> <ToggleGroupItem value="list" aria-label="List view" className="h-10 w-10 sm:h-9 sm:w-9">
</ToggleGroup> <IconList size={18} className="sm:size-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,121 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
IconHome,
IconHomeFilled,
IconFolder,
IconFolderFilled,
IconUsers,
IconUsersGroup,
IconFile,
IconFileFilled,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
interface NavItemProps {
href: string
icon: React.ReactNode
activeIcon: React.ReactNode
label: string
isActive: boolean
}
function NavItem({
href,
icon,
activeIcon,
label,
isActive,
}: NavItemProps) {
return (
<Link
href={href}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={cn(
"flex h-7 w-14 items-center justify-center",
"rounded-full transition-colors duration-200",
isActive ? "bg-primary/12" : "bg-transparent"
)}
>
<span
className={cn(
isActive
? "text-primary"
: "text-muted-foreground"
)}
>
{isActive ? activeIcon : icon}
</span>
</div>
<span
className={cn(
"text-[11px] leading-tight",
isActive
? "font-semibold text-primary"
: "font-medium text-muted-foreground"
)}
>
{label}
</span>
</Link>
)
}
export function MobileBottomNav() {
const pathname = usePathname()
const isActive = (path: string) => {
if (path === "/dashboard") return pathname === "/dashboard"
return pathname.startsWith(path)
}
return (
<nav
className={cn(
"fixed bottom-0 left-0 right-0 z-50 md:hidden",
"border-t bg-background"
)}
>
<div className="grid h-14 grid-cols-4 items-center pb-[env(safe-area-inset-bottom)]">
<NavItem
href="/dashboard"
icon={<IconHome className="size-[22px]" />}
activeIcon={<IconHomeFilled className="size-[22px]" />}
label="Home"
isActive={isActive("/dashboard")}
/>
<NavItem
href="/dashboard/projects"
icon={<IconFolder className="size-[22px]" />}
activeIcon={
<IconFolderFilled className="size-[22px]" />
}
label="Projects"
isActive={isActive("/dashboard/projects")}
/>
<NavItem
href="/dashboard/people"
icon={<IconUsers className="size-[22px]" />}
activeIcon={
<IconUsersGroup className="size-[22px]" />
}
label="People"
isActive={isActive("/dashboard/people")}
/>
<NavItem
href="/dashboard/files"
icon={<IconFile className="size-[22px]" />}
activeIcon={
<IconFileFilled className="size-[22px]" />
}
label="Files"
isActive={isActive("/dashboard/files")}
/>
</div>
</nav>
)
}

123
src/components/mobile-fab.tsx Executable file
View File

@ -0,0 +1,123 @@
"use client"
import * as React from "react"
import { IconPlus } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
export interface FabAction {
icon: React.ReactNode
label: string
onClick: () => void
}
interface MobileFabProps {
icon?: React.ReactNode
label?: string
actions?: FabAction[]
onClick?: () => void
}
export function MobileFab({
icon,
label,
actions,
onClick,
}: MobileFabProps) {
const [open, setOpen] = React.useState(false)
const handleClick = () => {
if (onClick) {
onClick()
return
}
if (actions?.length) {
setOpen(true)
}
}
const fabIcon = icon ?? (
<IconPlus className="size-5 text-primary" />
)
return (
<>
{label ? (
<button
className={cn(
"fixed bottom-[72px] right-3 z-[55]",
"flex h-12 items-center gap-2 px-4",
"rounded-full bg-background shadow-md border",
"transition-transform active:scale-95 md:hidden"
)}
onClick={handleClick}
aria-label={label}
>
{fabIcon}
<span className="text-sm font-medium">{label}</span>
</button>
) : (
<button
className={cn(
"fixed bottom-[72px] right-3 z-[55]",
"flex size-12 items-center justify-center",
"rounded-full bg-background shadow-md border",
"transition-transform active:scale-95 md:hidden"
)}
onClick={handleClick}
aria-label="Quick actions"
>
{fabIcon}
</button>
)}
{actions && actions.length > 0 && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="bottom"
className="p-0"
showClose={false}
>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">
Quick Actions
</SheetTitle>
</SheetHeader>
<div className="py-2">
{actions.map((action) => (
<button
key={action.label}
className={cn(
"flex w-full items-center gap-3",
"px-4 py-3 text-left active:bg-muted/50"
)}
onClick={() => {
action.onClick()
setOpen(false)
}}
>
<span
className={cn(
"flex size-10 items-center",
"justify-center rounded-full bg-muted"
)}
>
{action.icon}
</span>
<span className="text-sm font-medium">
{action.label}
</span>
</button>
))}
</div>
</SheetContent>
</Sheet>
)}
</>
)
}

View File

@ -0,0 +1,162 @@
"use client"
import * as React from "react"
import { IconChevronDown, IconArrowsSort } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
export interface FilterOption {
label: string
value: string
}
export interface FilterConfig {
id: string
label: string
value: string
options: FilterOption[]
}
export interface SortOption {
label: string
value: string
}
interface MobileFilterBarProps {
filters: FilterConfig[]
sortOptions?: SortOption[]
currentSort?: string
onFilterChange?: (filterId: string, value: string) => void
onSortChange?: (value: string) => void
}
function FilterChip({
label,
value,
isActive,
onClick,
}: {
label: string
value: string
isActive: boolean
onClick: () => void
}) {
return (
<button
className={cn(
"inline-flex h-8 shrink-0 items-center gap-1 rounded-full border px-3 text-sm active:bg-muted",
isActive
? "border-primary bg-primary/10"
: "bg-background"
)}
onClick={onClick}
>
<span className="text-muted-foreground">{label}:</span>
<span className="font-medium">{value}</span>
<IconChevronDown className="size-3" />
</button>
)
}
export function MobileFilterBar({
filters,
sortOptions,
currentSort,
onFilterChange,
onSortChange,
}: MobileFilterBarProps) {
const [activeFilter, setActiveFilter] = React.useState<string | null>(null)
const [sortOpen, setSortOpen] = React.useState(false)
const activeFilterConfig = filters.find((f) => f.id === activeFilter)
return (
<>
<div className="flex items-center gap-2 overflow-x-auto border-b p-2 md:hidden [&::-webkit-scrollbar]:hidden">
{filters.map((filter) => (
<FilterChip
key={filter.id}
label={filter.label}
value={filter.value}
isActive={filter.value !== "All" && filter.value !== "Any time"}
onClick={() => setActiveFilter(filter.id)}
/>
))}
{sortOptions && (
<Button
variant="outline"
size="sm"
className="shrink-0 h-8 rounded-full"
onClick={() => setSortOpen(true)}
>
<IconArrowsSort className="mr-1 size-3" />
Sort
</Button>
)}
</div>
{/* filter options sheet */}
<Sheet
open={!!activeFilter}
onOpenChange={(open) => !open && setActiveFilter(null)}
>
<SheetContent side="bottom" className="p-0" showClose={false}>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">
{activeFilterConfig?.label || "Filter"}
</SheetTitle>
</SheetHeader>
<div className="py-2">
{activeFilterConfig?.options.map((option) => (
<button
key={option.value}
className={cn(
"flex w-full items-center px-4 py-3 text-left text-sm active:bg-muted/50",
option.value === activeFilterConfig.value && "font-medium text-primary"
)}
onClick={() => {
onFilterChange?.(activeFilterConfig.id, option.value)
setActiveFilter(null)
}}
>
{option.label}
</button>
))}
</div>
</SheetContent>
</Sheet>
{/* sort options sheet */}
<Sheet open={sortOpen} onOpenChange={setSortOpen}>
<SheetContent side="bottom" className="p-0" showClose={false}>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">Sort by</SheetTitle>
</SheetHeader>
<div className="py-2">
{sortOptions?.map((option) => (
<button
key={option.value}
className={cn(
"flex w-full items-center px-4 py-3 text-left text-sm active:bg-muted/50",
option.value === currentSort && "font-medium text-primary"
)}
onClick={() => {
onSortChange?.(option.value)
setSortOpen(false)
}}
>
{option.label}
</button>
))}
</div>
</SheetContent>
</Sheet>
</>
)
}

View File

@ -0,0 +1,130 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import {
IconChevronDown,
IconFolder,
IconCheck,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { useProjectList } from "@/components/project-list-provider"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
interface MobileProjectSwitcherProps {
projectName: string
projectId: string
status?: string
}
export function MobileProjectSwitcher({
projectName,
projectId,
status,
}: MobileProjectSwitcherProps) {
const isMobile = useIsMobile()
const router = useRouter()
const projects = useProjectList()
const [open, setOpen] = React.useState(false)
// on desktop or single project, just render name normally
if (!isMobile || projects.length < 2) {
return (
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-1">
<h1 className="text-2xl font-semibold">
{projectName}
</h1>
{status && (
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{status}
</span>
)}
</div>
)
}
return (
<>
<button
onClick={() => setOpen(true)}
className="text-left mb-1 active:opacity-70"
>
<span className="text-2xl font-semibold">
{projectName}
</span>
<IconChevronDown className="size-4 text-muted-foreground inline ml-1 -mt-0.5 align-middle" />
</button>
{status && (
<div>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{status}
</span>
</div>
)}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="bottom"
className="p-0"
showClose={false}
>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">
Projects
</SheetTitle>
</SheetHeader>
<div className="max-h-[60vh] overflow-y-auto py-1">
{projects.map((project) => {
const isActive = project.id === projectId
return (
<button
key={project.id}
className={cn(
"flex w-full items-center gap-3",
"px-4 py-3 text-left",
"active:bg-muted/50",
isActive && "bg-muted/30"
)}
onClick={() => {
setOpen(false)
if (!isActive) {
router.push(
`/dashboard/projects/${project.id}`
)
}
}}
>
<IconFolder
className={cn(
"size-5 shrink-0",
isActive
? "text-primary"
: "text-muted-foreground"
)}
/>
<span
className={cn(
"text-sm flex-1 truncate",
isActive && "font-medium"
)}
>
{project.name}
</span>
{isActive && (
<IconCheck className="size-4 text-primary shrink-0" />
)}
</button>
)
})}
</div>
</SheetContent>
</Sheet>
</>
)
}

552
src/components/mobile-search.tsx Executable file
View File

@ -0,0 +1,552 @@
"use client"
import * as React from "react"
import { createPortal } from "react-dom"
import { useRouter } from "next/navigation"
import { useTheme } from "next-themes"
import {
IconArrowLeft,
IconX,
IconDashboard,
IconFolder,
IconFiles,
IconCalendarStats,
IconSun,
IconSearch,
IconCheck,
IconUsers,
IconBuildingStore,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { getCustomers } from "@/app/actions/customers"
import { getVendors } from "@/app/actions/vendors"
import { getProjects } from "@/app/actions/projects"
interface MobileSearchProps {
open: boolean
setOpen: (open: boolean) => void
}
type ItemCategory =
| "navigation"
| "action"
| "customer"
| "vendor"
| "project"
interface SearchItem {
icon: typeof IconDashboard
label: string
sublabel?: string
href?: string
category: ItemCategory
action?: string
createdAt?: string
}
const staticItems: SearchItem[] = [
{
icon: IconDashboard,
label: "Dashboard",
href: "/dashboard",
category: "navigation",
},
{
icon: IconFolder,
label: "Projects",
href: "/dashboard/projects",
category: "navigation",
},
{
icon: IconFiles,
label: "Files",
href: "/dashboard/files",
category: "navigation",
},
{
icon: IconCalendarStats,
label: "Schedule",
href: "/dashboard/projects/demo-project-1/schedule",
category: "navigation",
},
{
icon: IconSun,
label: "Toggle theme",
category: "action",
action: "toggle-theme",
},
{
icon: IconSearch,
label: "Search files",
category: "action",
action: "search-files",
},
]
const typeFilters = [
{ value: "all", label: "All" },
{ value: "navigation", label: "Navigation" },
{ value: "action", label: "Actions" },
{ value: "customer", label: "Customers" },
{ value: "vendor", label: "Vendors" },
{ value: "project", label: "Projects" },
]
const modifiedFilters = [
{ value: "any", label: "Any time" },
{ value: "today", label: "Today" },
{ value: "week", label: "This week" },
{ value: "month", label: "This month" },
]
function isWithinRange(
dateStr: string | undefined,
range: string
): boolean {
if (!dateStr) return range === "any"
const date = new Date(dateStr)
const now = new Date()
switch (range) {
case "today": {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return date >= start
}
case "week": {
const start = new Date(now)
start.setDate(start.getDate() - 7)
return date >= start
}
case "month": {
const start = new Date(now)
start.setDate(start.getDate() - 30)
return date >= start
}
default:
return true
}
}
export function MobileSearch({
open,
setOpen,
}: MobileSearchProps) {
const router = useRouter()
const { theme, setTheme } = useTheme()
const [query, setQuery] = React.useState("")
const [visible, setVisible] = React.useState(false)
const [animating, setAnimating] = React.useState(false)
const [typeFilter, setTypeFilter] = React.useState("all")
const [modifiedFilter, setModifiedFilter] =
React.useState("any")
const [activeSheet, setActiveSheet] = React.useState<
"type" | "modified" | null
>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const [mounted, setMounted] = React.useState(false)
const [dynamicItems, setDynamicItems] = React.useState<
SearchItem[]
>([])
const loadedRef = React.useRef(false)
React.useEffect(() => {
setMounted(true)
}, [])
React.useEffect(() => {
if (open) {
setQuery("")
setTypeFilter("all")
setModifiedFilter("any")
setVisible(true)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setAnimating(true)
inputRef.current?.focus()
})
})
if (!loadedRef.current) {
loadedRef.current = true
loadEntities()
}
} else {
setAnimating(false)
const timer = setTimeout(() => setVisible(false), 200)
return () => clearTimeout(timer)
}
}, [open])
async function loadEntities() {
try {
const [customers, vendors, projects] = await Promise.all([
getCustomers(),
getVendors(),
getProjects(),
])
const items: SearchItem[] = [
...customers.map((c) => ({
icon: IconUsers,
label: c.name,
sublabel: c.email || c.company || undefined,
href: "/dashboard/customers",
category: "customer" as const,
createdAt: c.createdAt,
})),
...vendors.map((v) => ({
icon: IconBuildingStore,
label: v.name,
sublabel: v.category,
href: "/dashboard/vendors",
category: "vendor" as const,
createdAt: v.createdAt,
})),
...(projects as { id: string; name: string; createdAt: string }[]).map((p) => ({
icon: IconFolder,
label: p.name,
href: `/dashboard/projects/${p.id}`,
category: "project" as const,
createdAt: p.createdAt,
})),
]
setDynamicItems(items)
} catch {
// static items still work
}
}
React.useEffect(() => {
if (visible) {
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = ""
}
}
}, [visible])
function close() {
setOpen(false)
}
function navigate(href: string) {
close()
router.push(href)
}
function runAction(action: string) {
if (action === "toggle-theme") {
setTheme(theme === "dark" ? "light" : "dark")
}
if (action === "search-files") {
inputRef.current?.focus()
return
}
close()
}
const allItems = [...staticItems, ...dynamicItems]
const filtered = allItems.filter((item) => {
if (
query.trim() &&
!item.label.toLowerCase().includes(query.toLowerCase())
) {
return false
}
if (typeFilter !== "all" && item.category !== typeFilter) {
return false
}
if (modifiedFilter !== "any") {
// static items (nav/actions) always pass date filter
if (
item.category === "navigation" ||
item.category === "action"
) {
return true
}
if (!isWithinRange(item.createdAt, modifiedFilter)) {
return false
}
}
return true
})
const grouped = {
navigation: filtered.filter(
(i) => i.category === "navigation"
),
action: filtered.filter((i) => i.category === "action"),
customer: filtered.filter((i) => i.category === "customer"),
vendor: filtered.filter((i) => i.category === "vendor"),
project: filtered.filter((i) => i.category === "project"),
}
const hasResults = Object.values(grouped).some(
(g) => g.length > 0
)
const typeLabel =
typeFilters.find((f) => f.value === typeFilter)?.label ??
"Type"
const modifiedLabel =
modifiedFilters.find((f) => f.value === modifiedFilter)
?.label ?? "Modified"
if (!mounted || !visible) return null
function renderGroup(label: string, items: SearchItem[]) {
if (items.length === 0) return null
return (
<>
<p className="px-4 py-2 text-xs font-medium text-muted-foreground">
{label}
</p>
{items.map((item, i) => (
<button
key={`${item.category}-${item.label}-${i}`}
className="flex w-full items-center gap-3 px-4 py-3 text-left active:bg-muted/50"
onClick={() =>
item.action
? runAction(item.action)
: item.href && navigate(item.href)
}
>
<item.icon className="size-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm block truncate">
{item.label}
</span>
{item.sublabel && (
<span className="text-xs text-muted-foreground block truncate">
{item.sublabel}
</span>
)}
</div>
</button>
))}
</>
)
}
const content = (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100dvh",
zIndex: 9999,
}}
className={cn(
"bg-background flex flex-col",
"transition-all duration-200 ease-out",
animating
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-2"
)}
>
{/* search header */}
<div className="flex items-center gap-1 px-2 h-14 border-b shrink-0">
<button
onClick={close}
className={cn(
"flex size-10 items-center justify-center",
"rounded-full shrink-0 active:bg-muted/50"
)}
aria-label="Close search"
>
<IconArrowLeft className="size-5 text-foreground" />
</button>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
className={cn(
"flex-1 h-10 bg-transparent text-base",
"text-foreground placeholder:text-muted-foreground",
"outline-none"
)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
{query && (
<button
onClick={() => {
setQuery("")
inputRef.current?.focus()
}}
className={cn(
"flex size-10 items-center justify-center",
"rounded-full shrink-0 active:bg-muted/50"
)}
aria-label="Clear search"
>
<IconX className="size-5 text-muted-foreground" />
</button>
)}
</div>
{/* filter chips */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b overflow-x-auto shrink-0">
<FilterChip
label="Type"
value={typeFilter === "all" ? undefined : typeLabel}
active={typeFilter !== "all"}
onClick={() => setActiveSheet("type")}
/>
<FilterChip
label="Modified"
value={
modifiedFilter === "any"
? undefined
: modifiedLabel
}
active={modifiedFilter !== "any"}
onClick={() => setActiveSheet("modified")}
/>
</div>
{/* results */}
<div className="flex-1 overflow-y-auto">
{!hasResults ? (
<div className="py-12 text-center text-sm text-muted-foreground">
No results found.
</div>
) : (
<div className="py-1">
{renderGroup("Navigation", grouped.navigation)}
{renderGroup("Actions", grouped.action)}
{renderGroup("Customers", grouped.customer)}
{renderGroup("Vendors", grouped.vendor)}
{renderGroup("Projects", grouped.project)}
</div>
)}
</div>
{/* type filter sheet */}
<Sheet
open={activeSheet === "type"}
onOpenChange={(v) => !v && setActiveSheet(null)}
>
<SheetContent
side="bottom"
className="p-0"
showClose={false}
>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">
Filter by type
</SheetTitle>
</SheetHeader>
<div className="py-1">
{typeFilters.map((f) => (
<button
key={f.value}
className="flex w-full items-center gap-3 px-4 py-3 text-left active:bg-muted/50"
onClick={() => {
setTypeFilter(f.value)
setActiveSheet(null)
}}
>
<span className="text-sm flex-1">
{f.label}
</span>
{typeFilter === f.value && (
<IconCheck className="size-4 text-primary shrink-0" />
)}
</button>
))}
</div>
</SheetContent>
</Sheet>
{/* modified filter sheet */}
<Sheet
open={activeSheet === "modified"}
onOpenChange={(v) => !v && setActiveSheet(null)}
>
<SheetContent
side="bottom"
className="p-0"
showClose={false}
>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">
Filter by date
</SheetTitle>
</SheetHeader>
<div className="py-1">
{modifiedFilters.map((f) => (
<button
key={f.value}
className="flex w-full items-center gap-3 px-4 py-3 text-left active:bg-muted/50"
onClick={() => {
setModifiedFilter(f.value)
setActiveSheet(null)
}}
>
<span className="text-sm flex-1">
{f.label}
</span>
{modifiedFilter === f.value && (
<IconCheck className="size-4 text-primary shrink-0" />
)}
</button>
))}
</div>
</SheetContent>
</Sheet>
</div>
)
return createPortal(content, document.body)
}
function FilterChip({
label,
value,
active,
onClick,
}: {
label: string
value?: string
active?: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"inline-flex h-8 shrink-0 items-center gap-1",
"rounded-full border px-3",
"text-sm active:bg-muted/50",
active
? "border-primary/30 bg-primary/5 text-foreground"
: "bg-background text-muted-foreground"
)}
>
{value ?? label}
<svg
className="size-3 ml-0.5"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4.5L6 7.5L9 4.5" />
</svg>
</button>
)
}

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useState } from "react"
import { import {
IconBell, IconBell,
IconMessageCircle, IconMessageCircle,
@ -9,11 +10,20 @@ import {
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { BadgeIndicator } from "@/components/ui/badge-indicator"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/use-mobile"
const notifications = [ const notifications = [
{ {
@ -42,43 +52,71 @@ const notifications = [
}, },
] ]
export function NotificationsPopover() { function NotificationsList() {
return ( return (
<Popover> <>
<PopoverTrigger asChild> <div className="max-h-[60vh] overflow-y-auto">
<Button variant="ghost" size="icon" className="relative size-8"> {notifications.map((item, index) => (
<IconBell className="size-4" /> <div
<span className="bg-destructive absolute top-1 right-1 size-1.5 rounded-full" /> key={`${item.title}-${index}`}
className="hover:bg-muted/50 flex gap-3 border-b px-4 py-3 last:border-0"
>
<item.icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-muted-foreground line-clamp-2 break-words text-xs">
{item.description}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{item.time}
</p>
</div>
</div>
))}
</div>
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="h-9 w-full text-xs">
Mark all as read
</Button> </Button>
</PopoverTrigger> </div>
</>
)
}
export function NotificationsPopover() {
const isMobile = useIsMobile()
const [open, setOpen] = useState(false)
const trigger = (
<Button variant="ghost" size="icon" className="relative size-8">
<BadgeIndicator dot>
<IconBell className="size-4" />
</BadgeIndicator>
</Button>
)
if (isMobile) {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent side="bottom" className="p-0" showClose={false}>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle className="text-base font-medium">Notifications</SheetTitle>
</SheetHeader>
<NotificationsList />
</SheetContent>
</Sheet>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0"> <PopoverContent align="end" className="w-80 p-0">
<div className="border-b px-4 py-3"> <div className="border-b px-4 py-3">
<p className="text-sm font-medium">Notifications</p> <p className="text-sm font-medium">Notifications</p>
</div> </div>
<div className="max-h-72 overflow-y-auto"> <NotificationsList />
{notifications.map((item) => (
<div
key={item.title}
className="hover:bg-muted/50 flex gap-3 border-b px-4 py-3 last:border-0"
>
<item.icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-muted-foreground truncate text-xs">
{item.description}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{item.time}
</p>
</div>
</div>
))}
</div>
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="w-full text-xs">
Mark all as read
</Button>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )

View File

@ -0,0 +1,25 @@
"use client"
import * as React from "react"
type Project = { id: string; name: string }
const ProjectListContext = React.createContext<Project[]>([])
export function useProjectList() {
return React.useContext(ProjectListContext)
}
export function ProjectListProvider({
projects,
children,
}: {
projects: Project[]
children: React.ReactNode
}) {
return (
<ProjectListContext.Provider value={projects}>
{children}
</ProjectListContext.Provider>
)
}

View File

@ -134,17 +134,18 @@ export function ScheduleBaselineView({
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-6">
<Input <Input
placeholder="Baseline name..." placeholder="Baseline name..."
value={baselineName} value={baselineName}
onChange={(e) => setBaselineName(e.target.value)} onChange={(e) => setBaselineName(e.target.value)}
className="max-w-[250px]" className="flex-1 sm:max-w-[250px]"
/> />
<Button <Button
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
disabled={saving || !baselineName.trim()} disabled={saving || !baselineName.trim()}
className="whitespace-nowrap"
> >
Save Baseline Save Baseline
</Button> </Button>

View File

@ -104,19 +104,20 @@ export function ScheduleCalendarView({
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentDate(new Date())} onClick={() => setCurrentDate(new Date())}
className="h-9"
> >
Today Today
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7" className="size-9"
onClick={() => setCurrentDate(subMonths(currentDate, 1))} onClick={() => setCurrentDate(subMonths(currentDate, 1))}
> >
<IconChevronLeft className="size-4" /> <IconChevronLeft className="size-4" />
@ -124,17 +125,17 @@ export function ScheduleCalendarView({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7" className="size-9"
onClick={() => setCurrentDate(addMonths(currentDate, 1))} onClick={() => setCurrentDate(addMonths(currentDate, 1))}
> >
<IconChevronRight className="size-4" /> <IconChevronRight className="size-4" />
</Button> </Button>
<h2 className="text-lg font-medium"> <h2 className="text-base sm:text-lg font-medium whitespace-nowrap">
{format(currentDate, "MMMM, yyyy")} {format(currentDate, "MMMM yyyy")}
</h2> </h2>
</div> </div>
<Select defaultValue="month"> <Select defaultValue="month">
<SelectTrigger className="h-7 w-[100px] text-xs"> <SelectTrigger className="h-9 w-28 text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -174,15 +175,15 @@ export function ScheduleCalendarView({
return ( return (
<div <div
key={dateKey} key={dateKey}
className={`min-h-0 border-r border-b last:border-r-0 p-1 ${ className={`min-h-[60px] sm:min-h-[80px] border-r border-b last:border-r-0 p-1 sm:p-1.5 ${
!inMonth ? "bg-muted/30" : "" !inMonth ? "bg-muted/30" : ""
} ${isNonWork ? "bg-muted/50" : ""}`} } ${isNonWork ? "bg-muted/50" : ""}`}
> >
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-start justify-between mb-0.5 min-w-0">
<span <span
className={`text-xs ${ className={`text-xs shrink-0 ${
isToday(day) isToday(day)
? "bg-primary text-primary-foreground rounded-full size-5 flex items-center justify-center font-bold" ? "bg-primary text-primary-foreground rounded-full size-5 sm:size-6 flex items-center justify-center font-bold"
: inMonth : inMonth
? "text-foreground" ? "text-foreground"
: "text-muted-foreground" : "text-muted-foreground"
@ -191,8 +192,9 @@ export function ScheduleCalendarView({
{format(day, "d")} {format(day, "d")}
</span> </span>
{isNonWork && ( {isNonWork && (
<span className="text-[9px] text-muted-foreground"> <span className="text-[8px] sm:text-[9px] text-muted-foreground truncate ml-1">
Non-workday <span className="hidden sm:inline">Non-workday</span>
<span className="sm:hidden">Off</span>
</span> </span>
)} )}
</div> </div>
@ -200,26 +202,26 @@ export function ScheduleCalendarView({
{visibleTasks.map((task) => ( {visibleTasks.map((task) => (
<div <div
key={task.id} key={task.id}
className={`${getTaskColor(task)} text-white text-[9px] px-1 py-0.5 rounded truncate`} className={`${getTaskColor(task)} text-white text-[9px] sm:text-[10px] px-1 py-0.5 rounded truncate`}
title={task.title} title={task.title}
> >
{task.title} {task.title.length > 15 ? `${task.title.slice(0, 12)}...` : task.title}
</div> </div>
))} ))}
{!expanded && overflow > 0 && ( {!expanded && overflow > 0 && (
<button <button
className="text-[9px] text-primary hover:underline" className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)} onClick={() => toggleExpand(dateKey)}
> >
+{overflow} more +{overflow}
</button> </button>
)} )}
{expanded && dayTasks.length > MAX_VISIBLE_TASKS && ( {expanded && dayTasks.length > MAX_VISIBLE_TASKS && (
<button <button
className="text-[9px] text-primary hover:underline" className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)} onClick={() => toggleExpand(dateKey)}
> >
Show less Less
</button> </button>
)} )}
</div> </div>

View File

@ -8,6 +8,11 @@ import {
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { import {
Table, Table,
TableBody, TableBody,
@ -24,9 +29,15 @@ import {
IconUsers, IconUsers,
IconZoomIn, IconZoomIn,
IconZoomOut, IconZoomOut,
IconPointer,
IconHandGrab,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useIsMobile } from "@/hooks/use-mobile"
import { GanttChart } from "./gantt-chart" import { GanttChart } from "./gantt-chart"
import { TaskFormDialog } from "./task-form-dialog" import { TaskFormDialog } from "./task-form-dialog"
import { import {
@ -58,6 +69,7 @@ export function ScheduleGanttView({
dependencies, dependencies,
}: ScheduleGanttViewProps) { }: ScheduleGanttViewProps) {
const router = useRouter() const router = useRouter()
const isMobile = useIsMobile()
const [viewMode, setViewMode] = useState<ViewMode>("Week") const [viewMode, setViewMode] = useState<ViewMode>("Week")
const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off") const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off")
const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>( const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>(
@ -68,8 +80,9 @@ export function ScheduleGanttView({
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>( const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
null null
) )
const [mobileView, setMobileView] = useState<"tasks" | "chart">("chart")
const [panMode, setPanMode] = useState(false) const [panMode] = useState(false)
const defaultWidths: Record<ViewMode, number> = { const defaultWidths: Record<ViewMode, number> = {
Day: 38, Week: 140, Month: 120, Day: 38, Week: 140, Month: 120,
@ -152,215 +165,359 @@ export function ScheduleGanttView({
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => ( {isMobile && (
<Button <Select
key={mode} value={mobileView}
size="sm" onValueChange={(val) => setMobileView(val as "tasks" | "chart")}
variant={viewMode === mode ? "default" : "outline"}
onClick={() => handleViewModeChange(mode)}
> >
{mode} <SelectTrigger className="h-9 w-24">
</Button> <SelectValue />
))} </SelectTrigger>
<SelectContent>
<SelectItem value="chart">Chart</SelectItem>
<SelectItem value="tasks">Tasks</SelectItem>
</SelectContent>
</Select>
)}
<Select
value={viewMode}
onValueChange={(val) => handleViewModeChange(val as ViewMode)}
>
<SelectTrigger className="h-9 w-24 sm:w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Day">Day</SelectItem>
<SelectItem value="Week">Week</SelectItem>
<SelectItem value="Month">Month</SelectItem>
</SelectContent>
</Select>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={scrollToToday} onClick={scrollToToday}
className="h-9 px-3"
> >
Today Today
</Button> </Button>
<Button
variant={panMode ? "default" : "outline"}
size="icon"
className="size-7"
onClick={() => setPanMode((p) => !p)}
title={panMode ? "Pan mode (click to switch to pointer)" : "Pointer mode (click to switch to pan)"}
>
{panMode
? <IconHandGrab className="size-3.5" />
: <IconPointer className="size-3.5" />}
</Button>
<Button
variant="outline"
size="icon"
className="size-7"
onClick={() => handleZoom("out")}
>
<IconZoomOut className="size-3.5" />
</Button>
<Button
variant="outline"
size="icon"
className="size-7"
onClick={() => handleZoom("in")}
>
<IconZoomIn className="size-3.5" />
</Button>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1">
<Switch <Button
checked={isGrouped} variant="outline"
onCheckedChange={(checked) => { size="icon"
setPhaseGrouping(checked ? "grouped" : "off") className="size-9"
if (!checked) setCollapsedPhases(new Set()) onClick={() => handleZoom("out")}
}} title="Zoom out"
className="scale-75" >
/> <IconZoomOut className="size-4" />
<span className="text-xs text-muted-foreground"> </Button>
Phases <Button
</span> variant="outline"
size="icon"
className="size-9"
onClick={() => handleZoom("in")}
title="Zoom in"
>
<IconZoomIn className="size-4" />
</Button>
</div> </div>
<div className="flex items-center gap-1.5"> <DropdownMenu>
<Switch <DropdownMenuTrigger asChild>
checked={showCriticalPath} <Button variant="outline" size="sm" className="h-9">
onCheckedChange={setShowCriticalPath} <IconUsers className="size-4 sm:mr-2" />
className="scale-75" <span className="hidden sm:inline">Options</span>
/> </Button>
<span className="text-xs text-muted-foreground"> </DropdownMenuTrigger>
Critical Path <DropdownMenuContent align="end" className="w-52">
</span> <div className="px-2 py-1.5">
</div> <div className="flex items-center justify-between mb-2">
<Button <span className="text-sm">Group by Phases</span>
variant={phaseGrouping === "grouped" && collapsedPhases.size > 0 ? "default" : "outline"} <Switch
size="sm" checked={isGrouped}
onClick={toggleClientView} onCheckedChange={(checked) => {
> setPhaseGrouping(checked ? "grouped" : "off")
<IconUsers className="size-3.5 mr-1" /> if (!checked) setCollapsedPhases(new Set())
Client View }}
</Button> className="scale-75"
/>
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm">Show Critical Path</span>
<Switch
checked={showCriticalPath}
onCheckedChange={setShowCriticalPath}
className="scale-75"
/>
</div>
<Button
variant={phaseGrouping === "grouped" && collapsedPhases.size > 0 ? "default" : "outline"}
size="sm"
onClick={toggleClientView}
className="w-full mt-2"
>
<IconUsers className="size-4 mr-2" />
<span className="hidden sm:inline">Client View</span>
<span className="sm:hidden">Client</span>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<ResizablePanelGroup {isMobile ? (
orientation="horizontal" <div className="flex flex-col flex-1 min-h-0">
className="border rounded-md flex-1 min-h-[300px]" {mobileView === "tasks" ? (
> <div className="border rounded-md flex-1 min-h-0 overflow-auto">
<ResizablePanel defaultSize={30} minSize={20}> <Table>
<div className="h-full overflow-auto"> <TableHeader>
<Table> <TableRow>
<TableHeader> <TableHead className="text-xs">Title</TableHead>
<TableRow> <TableHead className="text-xs w-[80px]">
<TableHead className="text-xs">Title</TableHead> Start
<TableHead className="text-xs w-[80px]"> </TableHead>
Start <TableHead className="text-xs w-[60px]">
</TableHead> Days
<TableHead className="text-xs w-[60px]"> </TableHead>
Days <TableHead className="w-[60px]" />
</TableHead> </TableRow>
<TableHead className="w-[60px]" /> </TableHeader>
</TableRow> <TableBody>
</TableHeader> {displayItems.map((item) => {
<TableBody> if (item.type === "phase-header") {
{displayItems.map((item) => { const { phase, group, collapsed } = item
if (item.type === "phase-header") { return (
const { phase, group, collapsed } = item <TableRow
return ( key={`phase-${phase}`}
<TableRow className="bg-muted/40 cursor-pointer hover:bg-muted/60"
key={`phase-${phase}`} onClick={() => togglePhase(phase)}
className="bg-muted/40 cursor-pointer hover:bg-muted/60"
onClick={() => togglePhase(phase)}
>
<TableCell
colSpan={collapsed ? 4 : 1}
className="text-xs py-1.5 font-medium"
> >
<span className="flex items-center gap-1"> <TableCell
{collapsed colSpan={collapsed ? 4 : 1}
? <IconChevronRight className="size-3.5" /> className="text-xs py-1.5 font-medium"
: <IconChevronDown className="size-3.5" />} >
{group.label} <span className="flex items-center gap-1">
<span className="text-muted-foreground font-normal ml-1"> {collapsed
({group.tasks.length}) ? <IconChevronRight className="size-3.5" />
</span> : <IconChevronDown className="size-3.5" />}
{collapsed && ( {group.label}
<span className="text-muted-foreground font-normal ml-auto text-[10px]"> <span className="text-muted-foreground font-normal ml-1">
{group.startDate.slice(5)} {group.endDate.slice(5)} ({group.tasks.length})
</span> </span>
)} {collapsed && (
<span className="text-muted-foreground font-normal ml-auto text-[10px]">
{group.startDate.slice(5)} {group.endDate.slice(5)}
</span>
)}
</span>
</TableCell>
{!collapsed && (
<>
<TableCell className="text-xs py-1.5 text-muted-foreground">
{group.startDate.slice(5)}
</TableCell>
<TableCell className="text-xs py-1.5" />
<TableCell className="py-1.5" />
</>
)}
</TableRow>
)
}
const { task } = item
return (
<TableRow key={task.id}>
<TableCell className="text-xs py-1.5 truncate max-w-[120px]">
<span className={isGrouped ? "pl-4" : ""}>
{task.title}
</span> </span>
</TableCell> </TableCell>
{!collapsed && ( <TableCell className="text-xs py-1.5 text-muted-foreground">
<> {task.startDate.slice(5)}
<TableCell className="text-xs py-1.5 text-muted-foreground"> </TableCell>
{group.startDate.slice(5)} <TableCell className="text-xs py-1.5">
</TableCell> {task.workdays}
<TableCell className="text-xs py-1.5" /> </TableCell>
<TableCell className="py-1.5" /> <TableCell className="py-1.5">
</> <Button
)} variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditingTask(task)
setTaskFormOpen(true)
}}
>
<IconPencil className="size-3" />
</Button>
</TableCell>
</TableRow> </TableRow>
) )
} })}
<TableRow>
const { task } = item <TableCell colSpan={4} className="py-1">
return ( <Button
<TableRow key={task.id}> variant="ghost"
<TableCell className="text-xs py-1.5 truncate max-w-[140px]"> size="sm"
<span className={isGrouped ? "pl-4" : ""}> className="text-xs w-full justify-start"
{task.title} onClick={() => {
</span> setEditingTask(null)
</TableCell> setTaskFormOpen(true)
<TableCell className="text-xs py-1.5 text-muted-foreground"> }}
{task.startDate.slice(5)} >
</TableCell> <IconPlus className="size-3 mr-1" />
<TableCell className="text-xs py-1.5"> Add Task
{task.workdays} </Button>
</TableCell> </TableCell>
<TableCell className="py-1.5"> </TableRow>
<Button </TableBody>
variant="ghost" </Table>
size="icon" </div>
className="size-6" ) : (
onClick={() => { <div className="border rounded-md flex-1 min-h-0 overflow-hidden p-2">
setEditingTask(task) <GanttChart
setTaskFormOpen(true) tasks={frappeTasks}
}} viewMode={viewMode}
columnWidth={columnWidth}
panMode={panMode}
onDateChange={handleDateChange}
onZoom={handleZoom}
/>
</div>
)}
</div>
) : (
<ResizablePanelGroup
orientation="horizontal"
className="border rounded-md flex-1 min-h-[300px]"
>
<ResizablePanel defaultSize={30} minSize={20}>
<div className="h-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Title</TableHead>
<TableHead className="text-xs w-[80px]">
Start
</TableHead>
<TableHead className="text-xs w-[60px]">
Days
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{displayItems.map((item) => {
if (item.type === "phase-header") {
const { phase, group, collapsed } = item
return (
<TableRow
key={`phase-${phase}`}
className="bg-muted/40 cursor-pointer hover:bg-muted/60"
onClick={() => togglePhase(phase)}
> >
<IconPencil className="size-3" /> <TableCell
</Button> colSpan={collapsed ? 4 : 1}
</TableCell> className="text-xs py-1.5 font-medium"
</TableRow> >
) <span className="flex items-center gap-1">
})} {collapsed
<TableRow> ? <IconChevronRight className="size-3.5" />
<TableCell colSpan={4} className="py-1"> : <IconChevronDown className="size-3.5" />}
<Button {group.label}
variant="ghost" <span className="text-muted-foreground font-normal ml-1">
size="sm" ({group.tasks.length})
className="text-xs w-full justify-start" </span>
onClick={() => { {collapsed && (
setEditingTask(null) <span className="text-muted-foreground font-normal ml-auto text-[10px]">
setTaskFormOpen(true) {group.startDate.slice(5)} {group.endDate.slice(5)}
}} </span>
> )}
<IconPlus className="size-3 mr-1" /> </span>
Add Task </TableCell>
</Button> {!collapsed && (
</TableCell> <>
</TableRow> <TableCell className="text-xs py-1.5 text-muted-foreground">
</TableBody> {group.startDate.slice(5)}
</Table> </TableCell>
</div> <TableCell className="text-xs py-1.5" />
</ResizablePanel> <TableCell className="py-1.5" />
</>
)}
</TableRow>
)
}
<ResizableHandle withHandle /> const { task } = item
return (
<TableRow key={task.id}>
<TableCell className="text-xs py-1.5 truncate max-w-[140px]">
<span className={isGrouped ? "pl-4" : ""}>
{task.title}
</span>
</TableCell>
<TableCell className="text-xs py-1.5 text-muted-foreground">
{task.startDate.slice(5)}
</TableCell>
<TableCell className="text-xs py-1.5">
{task.workdays}
</TableCell>
<TableCell className="py-1.5">
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditingTask(task)
setTaskFormOpen(true)
}}
>
<IconPencil className="size-3" />
</Button>
</TableCell>
</TableRow>
)
})}
<TableRow>
<TableCell colSpan={4} className="py-1">
<Button
variant="ghost"
size="sm"
className="text-xs w-full justify-start"
onClick={() => {
setEditingTask(null)
setTaskFormOpen(true)
}}
>
<IconPlus className="size-3 mr-1" />
Add Task
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ResizablePanel>
<ResizablePanel defaultSize={70} minSize={40}> <ResizableHandle withHandle />
<div className="h-full overflow-hidden p-2">
<GanttChart <ResizablePanel defaultSize={70} minSize={40}>
tasks={frappeTasks} <div className="h-full overflow-hidden p-2">
viewMode={viewMode} <GanttChart
columnWidth={columnWidth} tasks={frappeTasks}
panMode={panMode} viewMode={viewMode}
onDateChange={handleDateChange} columnWidth={columnWidth}
onZoom={handleZoom} panMode={panMode}
/> onDateChange={handleDateChange}
</div> onZoom={handleZoom}
</ResizablePanel> />
</ResizablePanelGroup> </div>
</ResizablePanel>
</ResizablePanelGroup>
)}
<TaskFormDialog <TaskFormDialog
open={taskFormOpen} open={taskFormOpen}

View File

@ -192,7 +192,7 @@ export function ScheduleListView({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusDot task={row.original} /> <StatusDot task={row.original} />
<span className="font-medium text-sm truncate max-w-[200px]"> <span className="font-medium text-sm truncate max-w-[150px] sm:max-w-[200px]">
{row.original.title} {row.original.title}
</span> </span>
</div> </div>
@ -205,6 +205,7 @@ export function ScheduleListView({
<ProgressRing percent={row.original.percentComplete} /> <ProgressRing percent={row.original.percentComplete} />
), ),
size: 70, size: 70,
meta: { className: "hidden sm:table-cell" },
}, },
{ {
accessorKey: "phase", accessorKey: "phase",
@ -214,6 +215,7 @@ export function ScheduleListView({
{row.original.phase} {row.original.phase}
</span> </span>
), ),
meta: { className: "hidden lg:table-cell" },
}, },
{ {
id: "duration", id: "duration",
@ -224,6 +226,7 @@ export function ScheduleListView({
</span> </span>
), ),
size: 80, size: 80,
meta: { className: "hidden md:table-cell" },
}, },
{ {
accessorKey: "startDate", accessorKey: "startDate",
@ -309,50 +312,58 @@ export function ScheduleListView({
</Button> </Button>
</div> </div>
<div className="rounded-md border flex-1 overflow-auto"> <div className="rounded-md border flex-1 overflow-x-auto -mx-2 sm:mx-0">
<Table> <div className="inline-block min-w-full align-middle">
<TableHeader> <Table>
{table.getHeaderGroups().map((headerGroup) => ( <TableHeader>
<TableRow key={headerGroup.id}> {table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => ( <TableRow key={headerGroup.id}>
<TableHead key={header.id}> {headerGroup.headers.map((header) => {
{header.isPlaceholder const meta = header.column.columnDef.meta as { className?: string } | undefined
? null return (
: flexRender( <TableHead key={header.id} className={meta?.className}>
header.column.columnDef.header, {header.isPlaceholder
header.getContext() ? null
)} : flexRender(
</TableHead> header.column.columnDef.header,
))} header.getContext()
</TableRow> )}
))} </TableHead>
</TableHeader> )
<TableBody> })}
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8 text-muted-foreground"
>
No tasks yet. Click &quot;New Schedule Item&quot; to get started.
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow> </TableRow>
)) ))}
)} </TableHeader>
</TableBody> <TableBody>
</Table> {table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8 text-muted-foreground"
>
No tasks yet. Click &quot;New Schedule Item&quot; to get started.
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as { className?: string } | undefined
return (
<TableCell key={cell.id} className={meta?.className}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
)
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div> </div>
<div className="flex items-center justify-between mt-3 px-1"> <div className="flex items-center justify-between mt-3 px-1">

View File

@ -0,0 +1,223 @@
"use client"
import { useState, useMemo } from "react"
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
addMonths,
subMonths,
isToday,
isSameMonth,
isSameDay,
parseISO,
} from "date-fns"
import { cn } from "@/lib/utils"
import type { ScheduleTaskData } from "@/lib/schedule/types"
interface ScheduleMobileViewProps {
tasks: ScheduleTaskData[]
exceptions?: unknown[]
onTaskClick?: (task: ScheduleTaskData) => void
}
const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
const WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"]
function getTaskColor(task: ScheduleTaskData): string {
if (task.status === "COMPLETE") return "bg-green-500"
if (task.status === "IN_PROGRESS") return "bg-blue-500"
if (task.status === "BLOCKED") return "bg-red-500"
if (task.isCriticalPath) return "bg-orange-500"
return "bg-muted-foreground"
}
function getTaskBorderColor(task: ScheduleTaskData): string {
if (task.status === "COMPLETE") return "border-green-500"
if (task.status === "IN_PROGRESS") return "border-blue-500"
if (task.status === "BLOCKED") return "border-red-500"
if (task.isCriticalPath) return "border-orange-500"
return "border-muted-foreground"
}
export function ScheduleMobileView({
tasks,
exceptions,
onTaskClick,
}: ScheduleMobileViewProps) {
const [currentDate, setCurrentDate] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const currentMonth = currentDate.getMonth()
const currentYear = currentDate.getFullYear()
const monthStart = startOfMonth(currentDate)
const monthEnd = endOfMonth(currentDate)
const calendarStart = startOfWeek(monthStart)
const calendarEnd = endOfWeek(monthEnd)
const days = useMemo(
() => eachDayOfInterval({ start: calendarStart, end: calendarEnd }),
[calendarStart.getTime(), calendarEnd.getTime()]
)
const tasksByDate = useMemo(() => {
const map = new Map<string, ScheduleTaskData[]>()
for (const task of tasks) {
const start = parseISO(task.startDate)
const end = parseISO(task.endDateCalculated)
const interval = eachDayOfInterval({ start, end })
for (const day of interval) {
const key = format(day, "yyyy-MM-dd")
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(task)
}
}
return map
}, [tasks])
const selectedDayTasks = useMemo(() => {
const key = format(selectedDate, "yyyy-MM-dd")
return tasksByDate.get(key) || []
}, [selectedDate, tasksByDate])
const handleMonthSelect = (monthIndex: number) => {
const newDate = new Date(currentYear, monthIndex, 1)
setCurrentDate(newDate)
}
return (
<div className="flex h-full flex-col">
{/* month pill navigation */}
<div className="flex gap-2 overflow-x-auto border-b px-4 py-2 [&::-webkit-scrollbar]:hidden">
{MONTHS.map((month, i) => (
<button
key={month}
className={cn(
"shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
i === currentMonth
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground active:bg-muted/80"
)}
onClick={() => handleMonthSelect(i)}
>
{month}
</button>
))}
</div>
{/* year + nav */}
<div className="flex items-center justify-between px-4 py-2">
<button
className="p-1 text-muted-foreground active:text-foreground"
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
>
&larr;
</button>
<span className="text-sm font-medium">
{format(currentDate, "MMMM yyyy")}
</span>
<button
className="p-1 text-muted-foreground active:text-foreground"
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
>
&rarr;
</button>
</div>
{/* weekday header */}
<div className="grid grid-cols-7 border-b text-center">
{WEEKDAYS.map((day, i) => (
<div
key={`${day}-${i}`}
className="bg-background py-2 text-xs font-medium text-muted-foreground"
>
{day}
</div>
))}
</div>
{/* day cells */}
<div className="grid grid-cols-7 gap-px bg-border">
{days.map((day) => {
const dateKey = format(day, "yyyy-MM-dd")
const dayTasks = tasksByDate.get(dateKey) || []
const inMonth = isSameMonth(day, currentDate)
const selected = isSameDay(day, selectedDate)
const today = isToday(day)
return (
<button
key={dateKey}
className={cn(
"relative bg-background p-2 text-sm min-h-[44px]",
!inMonth && "text-muted-foreground/40",
selected && "bg-primary/10",
)}
onClick={() => setSelectedDate(day)}
>
<span
className={cn(
"inline-flex size-7 items-center justify-center rounded-full text-xs",
today && "bg-primary text-primary-foreground font-bold",
selected && !today && "ring-2 ring-primary",
)}
>
{format(day, "d")}
</span>
{dayTasks.length > 0 && (
<div className="mt-0.5 flex justify-center gap-0.5">
{dayTasks.slice(0, 3).map((task, i) => (
<span
key={`${task.id}-${i}`}
className={cn("size-1 rounded-full", getTaskColor(task))}
/>
))}
</div>
)}
</button>
)
})}
</div>
{/* selected day events */}
<div className="flex-1 overflow-y-auto p-4">
<h3 className="mb-2 text-sm font-semibold">
{format(selectedDate, "EEE, MMM d")}
</h3>
{selectedDayTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No tasks scheduled</p>
) : (
<div className="space-y-2">
{selectedDayTasks.map((task) => (
<button
key={task.id}
className={cn(
"flex w-full gap-3 border-l-2 pl-3 py-2 text-left active:bg-muted/50 rounded-r-md",
getTaskBorderColor(task)
)}
onClick={() => onTaskClick?.(task)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{task.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{task.phase}</span>
<span>·</span>
<span>{task.percentComplete}% complete</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
)
}

View File

@ -7,7 +7,9 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
IconSettings, IconSettings,
@ -15,6 +17,9 @@ import {
IconFilter, IconFilter,
IconPlus, IconPlus,
IconDots, IconDots,
IconDownload,
IconUpload,
IconPrinter,
} from "@tabler/icons-react" } from "@tabler/icons-react"
interface ScheduleToolbarProps { interface ScheduleToolbarProps {
@ -25,45 +30,58 @@ export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) {
const [offlineMode, setOfflineMode] = useState(false) const [offlineMode, setOfflineMode] = useState(false)
return ( return (
<div className="flex flex-wrap items-center justify-between gap-2 py-1.5 border-b mb-2"> <div className="flex items-center justify-between gap-2 py-2 border-b mb-2">
<div className="flex items-center gap-1 sm:gap-3"> <DropdownMenu>
<Button variant="ghost" size="icon" className="size-8"> <DropdownMenuTrigger asChild>
<IconSettings className="size-4" /> <Button variant="outline" size="sm" className="h-9">
</Button> <IconDots className="size-4 mr-2" />
<Button variant="ghost" size="icon" className="size-8"> Actions
<IconHistory className="size-4" /> </Button>
</Button> </DropdownMenuTrigger>
<div className="flex items-center gap-2"> <DropdownMenuContent align="start" className="w-48">
<Switch <DropdownMenuItem>
checked={offlineMode} <IconSettings className="size-4 mr-2" />
onCheckedChange={setOfflineMode} Settings
className="scale-75" </DropdownMenuItem>
/> <DropdownMenuItem>
<span className="text-xs text-muted-foreground hidden sm:inline"> <IconHistory className="size-4 mr-2" />
Schedule Offline Version History
</span> </DropdownMenuItem>
</div> <DropdownMenuItem>
<DropdownMenu> <IconFilter className="size-4 mr-2" />
<DropdownMenuTrigger asChild> Filter Tasks
<Button variant="ghost" size="sm" className="text-xs h-8"> </DropdownMenuItem>
<IconDots className="size-4 sm:mr-1" /> <DropdownMenuSeparator />
<span className="hidden sm:inline">More Actions</span> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground px-2 py-1">
</Button> Import & Export
</DropdownMenuTrigger> </DropdownMenuLabel>
<DropdownMenuContent> <DropdownMenuItem>
<DropdownMenuItem>Export Schedule</DropdownMenuItem> <IconDownload className="size-4 mr-2" />
<DropdownMenuItem>Import Schedule</DropdownMenuItem> Export Schedule
<DropdownMenuItem>Print</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> <DropdownMenuItem>
</DropdownMenu> <IconUpload className="size-4 mr-2" />
<Button variant="ghost" size="sm" className="text-xs h-8"> Import Schedule
<IconFilter className="size-4 sm:mr-1" /> </DropdownMenuItem>
<span className="hidden sm:inline">Filter</span> <DropdownMenuItem>
</Button> <IconPrinter className="size-4 mr-2" />
</div> Print
<Button size="sm" onClick={onNewItem}> </DropdownMenuItem>
<IconPlus className="size-4 sm:mr-1" /> <DropdownMenuSeparator />
<span className="hidden sm:inline">New Schedule Item</span> <div className="flex items-center justify-between px-2 py-1.5">
<span className="text-sm">Schedule Offline</span>
<Switch
checked={offlineMode}
onCheckedChange={setOfflineMode}
className="scale-75"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" onClick={onNewItem} className="h-9">
<IconPlus className="size-4 mr-2" />
New Task
</Button> </Button>
</div> </div>
) )

View File

@ -2,10 +2,12 @@
import { useState } from "react" import { useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useIsMobile } from "@/hooks/use-mobile"
import { ScheduleToolbar } from "./schedule-toolbar" import { ScheduleToolbar } from "./schedule-toolbar"
import { ScheduleListView } from "./schedule-list-view" import { ScheduleListView } from "./schedule-list-view"
import { ScheduleGanttView } from "./schedule-gantt-view" import { ScheduleGanttView } from "./schedule-gantt-view"
import { ScheduleCalendarView } from "./schedule-calendar-view" import { ScheduleCalendarView } from "./schedule-calendar-view"
import { ScheduleMobileView } from "./schedule-mobile-view"
import { WorkdayExceptionsView } from "./workday-exceptions-view" import { WorkdayExceptionsView } from "./workday-exceptions-view"
import { ScheduleBaselineView } from "./schedule-baseline-view" import { ScheduleBaselineView } from "./schedule-baseline-view"
import { TaskFormDialog } from "./task-form-dialog" import { TaskFormDialog } from "./task-form-dialog"
@ -30,8 +32,9 @@ export function ScheduleView({
initialData, initialData,
baselines, baselines,
}: ScheduleViewProps) { }: ScheduleViewProps) {
const isMobile = useIsMobile()
const [topTab, setTopTab] = useState<TopTab>("schedule") const [topTab, setTopTab] = useState<TopTab>("schedule")
const [subTab, setSubTab] = useState<ScheduleSubTab>("list") const [subTab, setSubTab] = useState<ScheduleSubTab>("calendar")
const [taskFormOpen, setTaskFormOpen] = useState(false) const [taskFormOpen, setTaskFormOpen] = useState(false)
return ( return (
@ -89,11 +92,18 @@ export function ScheduleView({
</TabsList> </TabsList>
<TabsContent value="calendar" className="mt-2 flex flex-col flex-1 min-h-0"> <TabsContent value="calendar" className="mt-2 flex flex-col flex-1 min-h-0">
<ScheduleCalendarView {isMobile ? (
projectId={projectId} <ScheduleMobileView
tasks={initialData.tasks} tasks={initialData.tasks}
exceptions={initialData.exceptions} exceptions={initialData.exceptions}
/> />
) : (
<ScheduleCalendarView
projectId={projectId}
tasks={initialData.tasks}
exceptions={initialData.exceptions}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="list" className="mt-2 flex flex-col flex-1 min-h-0"> <TabsContent value="list" className="mt-2 flex flex-col flex-1 min-h-0">

View File

@ -1,15 +1,15 @@
"use client" "use client"
import * as React from "react"
import { useEffect, useMemo } from "react" import { useEffect, useMemo } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
Dialog, ResponsiveDialog,
DialogContent, ResponsiveDialogBody,
DialogHeader, ResponsiveDialogFooter,
DialogTitle, } from "@/components/ui/responsive-dialog"
} from "@/components/ui/dialog"
import { import {
Form, Form,
FormControl, FormControl,
@ -144,196 +144,206 @@ export function TaskFormDialog({
} }
} }
return ( const page1 = (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent> <FormField
<DialogHeader> control={form.control}
<DialogTitle> name="title"
{isEditing ? "Edit Task" : "New Task"} render={({ field }) => (
</DialogTitle> <FormItem>
</DialogHeader> <FormLabel className="text-xs">Title</FormLabel>
<FormControl>
<Input placeholder="Task title" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}> <FormField
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> control={form.control}
<FormField name="startDate"
control={form.control} render={({ field }) => (
name="title" <FormItem>
render={({ field }) => ( <FormLabel className="text-xs">Start Date</FormLabel>
<FormItem> <Popover>
<FormLabel>Title</FormLabel> <PopoverTrigger asChild>
<FormControl> <FormControl>
<Input placeholder="Task title" {...field} /> <Button
</FormControl> variant="outline"
<FormMessage /> className="w-full h-9 justify-start text-left font-normal text-sm"
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<IconCalendar className="size-4 mr-2 text-muted-foreground" />
{field.value
? format(parseISO(field.value), "MMM d, yyyy")
: "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? parseISO(field.value) : undefined}
onSelect={(date) => {
if (date) {
field.onChange(format(date, "yyyy-MM-dd"))
}
}}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="workdays"
render={({ field }) => (
<FormItem>
<FormLabel>Workdays</FormLabel>
<FormControl>
<Input
type="number"
min={1}
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value) || 0)
}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{calculatedEnd && (
<p className="text-sm text-muted-foreground">
Calculated end date: <strong>{calculatedEnd}</strong>
</p>
)}
<FormField
control={form.control}
name="phase"
render={({ field }) => (
<FormItem>
<FormLabel>Phase</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
> >
<FormControl> <IconCalendar className="size-3.5 mr-2 text-muted-foreground" />
<SelectTrigger> {field.value
<SelectValue placeholder="Select phase" /> ? format(parseISO(field.value), "MMM d, yyyy")
</SelectTrigger> : "Pick date"}
</FormControl> </Button>
<SelectContent> </FormControl>
{phases.map((p) => ( </PopoverTrigger>
<SelectItem key={p.value} value={p.value}> <PopoverContent className="w-auto p-0" align="start">
{p.label} <Calendar
</SelectItem> mode="single"
))} selected={field.value ? parseISO(field.value) : undefined}
</SelectContent> onSelect={(date) => {
</Select> if (date) {
<FormMessage /> field.onChange(format(date, "yyyy-MM-dd"))
</FormItem> }
)} }}
/> initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="percentComplete" name="workdays"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel className="text-xs">Workdays</FormLabel>
Complete: {field.value}% <FormControl>
</FormLabel> <Input
<FormControl> type="number"
<Slider min={1}
min={0} className="h-9"
max={100} value={field.value}
step={5} onChange={(e) =>
value={[field.value]} field.onChange(Number(e.target.value) || 0)
onValueChange={([val]) => field.onChange(val)} }
/> onBlur={field.onBlur}
</FormControl> ref={field.ref}
<FormMessage /> name={field.name}
</FormItem> />
)} </FormControl>
/> <FormMessage />
</FormItem>
)}
/>
</>
)
<FormField const page2 = (
control={form.control} <>
name="assignedTo" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="phase"
<FormLabel>Assigned To</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input placeholder="Person name" {...field} /> <FormLabel className="text-xs">Phase</FormLabel>
</FormControl> <Select
<FormMessage /> onValueChange={field.onChange}
</FormItem> value={field.value}
)} >
/> <FormControl>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select phase" />
</SelectTrigger>
</FormControl>
<SelectContent>
{phases.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="isMilestone" name="assignedTo"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex items-center gap-2"> <FormItem>
<FormControl> <FormLabel className="text-xs">Assigned To</FormLabel>
<Switch <FormControl>
checked={field.value} <Input placeholder="Person name" className="h-9" {...field} />
onCheckedChange={field.onChange} </FormControl>
/> <FormMessage />
</FormControl> </FormItem>
<FormLabel className="!mt-0">Milestone</FormLabel> )}
</FormItem> />
)} </>
/> )
<div className="flex justify-end gap-2"> const page3 = (
<Button <>
type="button" {calculatedEnd && (
variant="outline" <p className="text-xs text-muted-foreground">
onClick={() => onOpenChange(false)} End date: <strong>{calculatedEnd}</strong>
> </p>
Cancel )}
</Button>
<Button type="submit"> <FormField
{isEditing ? "Save" : "Create"} control={form.control}
</Button> name="percentComplete"
</div> render={({ field }) => (
</form> <FormItem>
</Form> <FormLabel className="text-xs">
</DialogContent> Complete: {field.value}%
</Dialog> </FormLabel>
<FormControl>
<Slider
min={0}
max={100}
step={5}
value={[field.value]}
onValueChange={([val]) => field.onChange(val)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isMilestone"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0 text-xs">Milestone</FormLabel>
</FormItem>
)}
/>
</>
)
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={isEditing ? "Edit Task" : "New Task"}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody pages={[page1, page2, page3]} />
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-9"
>
Cancel
</Button>
<Button type="submit" className="h-9">
{isEditing ? "Save" : "Create"}
</Button>
</ResponsiveDialogFooter>
</form>
</Form>
</ResponsiveDialog>
) )
} }

View File

@ -5,11 +5,10 @@ import { useForm } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
Dialog, ResponsiveDialog,
DialogContent, ResponsiveDialogBody,
DialogHeader, ResponsiveDialogFooter,
DialogTitle, } from "@/components/ui/responsive-dialog"
} from "@/components/ui/dialog"
import { import {
Form, Form,
FormControl, FormControl,
@ -145,39 +144,37 @@ export function WorkdayExceptionFormDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <ResponsiveDialog
<DialogContent> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle> title={isEditing ? "Edit Exception" : "New Workday Exception"}
{isEditing ? "Edit Exception" : "New Workday Exception"} >
</DialogTitle> <Form {...form}>
</DialogHeader> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Title</FormLabel> <FormLabel className="text-xs">Title</FormLabel>
<FormControl> <FormControl>
<Input placeholder="e.g. Christmas Day" {...field} /> <Input placeholder="e.g. Christmas Day" className="h-9" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<FormField <FormField
control={form.control} control={form.control}
name="startDate" name="startDate"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Start Date</FormLabel> <FormLabel className="text-xs">Start Date</FormLabel>
<FormControl> <FormControl>
<Input type="date" {...field} /> <Input type="date" className="h-9" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -188,9 +185,9 @@ export function WorkdayExceptionFormDialog({
name="endDate" name="endDate"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>End Date</FormLabel> <FormLabel className="text-xs">End Date</FormLabel>
<FormControl> <FormControl>
<Input type="date" {...field} /> <Input type="date" className="h-9" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -198,19 +195,19 @@ export function WorkdayExceptionFormDialog({
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<FormField <FormField
control={form.control} control={form.control}
name="category" name="category"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Category</FormLabel> <FormLabel className="text-xs">Category</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger className="h-9">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
@ -231,13 +228,13 @@ export function WorkdayExceptionFormDialog({
name="recurrence" name="recurrence"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Recurrence</FormLabel> <FormLabel className="text-xs">Recurrence</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger className="h-9">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
@ -260,12 +257,12 @@ export function WorkdayExceptionFormDialog({
name="notes" name="notes"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Notes</FormLabel> <FormLabel className="text-xs">Notes <span className="text-muted-foreground">(optional)</span></FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Optional notes..." placeholder="Optional notes..."
className="resize-none" className="resize-none text-sm"
rows={3} rows={1}
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -274,21 +271,23 @@ export function WorkdayExceptionFormDialog({
)} )}
/> />
<div className="flex justify-end gap-2"> </ResponsiveDialogBody>
<Button
type="button" <ResponsiveDialogFooter>
variant="outline" <Button
onClick={() => onOpenChange(false)} type="button"
> variant="outline"
Cancel onClick={() => onOpenChange(false)}
</Button> className="h-9"
<Button type="submit"> >
{isEditing ? "Save" : "Create"} Cancel
</Button> </Button>
</div> <Button type="submit" className="h-9">
</form> {isEditing ? "Save" : "Create"}
</Form> </Button>
</DialogContent> </ResponsiveDialogFooter>
</Dialog> </form>
</Form>
</ResponsiveDialog>
) )
} }

View File

@ -71,66 +71,69 @@ export function WorkdayExceptionsView({
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h2 className="text-lg font-medium">Workday Exceptions</h2> <h2 className="text-lg font-medium min-w-0 break-words">Workday Exceptions</h2>
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
setEditingException(null) setEditingException(null)
setFormOpen(true) setFormOpen(true)
}} }}
className="whitespace-nowrap"
> >
<IconPlus className="size-4 mr-1" /> <IconPlus className="size-4 mr-1" />
Workday Exception <span className="hidden sm:inline">Workday Exception</span>
<span className="sm:hidden">New Exception</span>
</Button> </Button>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border overflow-x-auto -mx-2 sm:mx-0">
<Table> <div className="inline-block min-w-full align-middle">
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>Title</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Category</TableHead>
<TableHead>Recurrence</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{exceptions.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableHead className="min-w-[120px]">Title</TableHead>
colSpan={8} <TableHead className="hidden sm:table-cell">Start</TableHead>
className="text-center py-8 text-muted-foreground" <TableHead className="hidden sm:table-cell">End</TableHead>
> <TableHead className="hidden md:table-cell">Duration</TableHead>
No workday exceptions defined. <TableHead className="hidden lg:table-cell">Category</TableHead>
</TableCell> <TableHead className="hidden lg:table-cell">Recurrence</TableHead>
<TableHead className="hidden lg:table-cell">Notes</TableHead>
<TableHead className="w-[80px]" />
</TableRow> </TableRow>
</TableHeader>
<TableBody>
{exceptions.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="text-center py-8 text-muted-foreground min-w-0"
>
<span className="block">No workday exceptions</span>
</TableCell>
</TableRow>
) : ( ) : (
exceptions.map((ex) => ( exceptions.map((ex) => (
<TableRow key={ex.id}> <TableRow key={ex.id}>
<TableCell className="font-medium text-sm"> <TableCell className="font-medium text-sm">
{ex.title} {ex.title}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="hidden sm:table-cell text-xs text-muted-foreground">
{formatDate(ex.startDate)} {formatDate(ex.startDate)}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="hidden sm:table-cell text-xs text-muted-foreground">
{formatDate(ex.endDate)} {formatDate(ex.endDate)}
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="hidden md:table-cell text-xs">
{calcDuration(ex.startDate, ex.endDate)} days {calcDuration(ex.startDate, ex.endDate)} days
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="hidden lg:table-cell text-xs">
{categoryLabels[ex.category] ?? ex.category} {categoryLabels[ex.category] ?? ex.category}
</TableCell> </TableCell>
<TableCell className="text-xs capitalize"> <TableCell className="hidden lg:table-cell text-xs capitalize">
{ex.recurrence.replace("_", " ")} {ex.recurrence.replace("_", " ")}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[120px]"> <TableCell className="hidden lg:table-cell text-xs text-muted-foreground truncate max-w-[120px]">
{ex.notes || "-"} {ex.notes || "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -161,6 +164,7 @@ export function WorkdayExceptionsView({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div>
</div> </div>
<WorkdayExceptionFormDialog <WorkdayExceptionFormDialog

View File

@ -4,12 +4,9 @@ import * as React from "react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { import {
Dialog, ResponsiveDialog,
DialogContent, ResponsiveDialogBody,
DialogDescription, } from "@/components/ui/responsive-dialog"
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { import {
@ -26,6 +23,8 @@ import {
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs" } from "@/components/ui/tabs"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls"
export function SettingsModal({ export function SettingsModal({
open, open,
@ -40,30 +39,153 @@ export function SettingsModal({
const [weeklyDigest, setWeeklyDigest] = React.useState(false) const [weeklyDigest, setWeeklyDigest] = React.useState(false)
const [timezone, setTimezone] = React.useState("America/New_York") const [timezone, setTimezone] = React.useState("America/New_York")
return ( const generalPage = (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-lg"> <div className="space-y-1.5">
<DialogHeader> <Label htmlFor="timezone" className="text-xs">
<DialogTitle>Settings</DialogTitle> Timezone
<DialogDescription> </Label>
Manage your app preferences. <Select value={timezone} onValueChange={setTimezone}>
</DialogDescription> <SelectTrigger id="timezone" className="w-full h-9">
</DialogHeader> <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>
<Tabs defaultValue="general" className="mt-2"> <Separator />
<TabsList>
<TabsTrigger value="general">General</TabsTrigger> <div className="flex items-center justify-between gap-4">
<TabsTrigger value="notifications"> <div className="min-w-0 flex-1">
<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>
</>
)
const notificationsPage = (
<>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<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>
<Separator />
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<Label className="text-xs">Push notifications</Label>
<p className="text-muted-foreground text-xs">
Receive push notifications in your browser.
</p>
</div>
<Switch
checked={pushNotifs}
onCheckedChange={setPushNotifs}
className="shrink-0"
/>
</div>
</>
)
const appearancePage = (
<div className="space-y-1.5">
<Label htmlFor="theme" className="text-xs">
Theme
</Label>
<Select
value={theme ?? "light"}
onValueChange={setTheme}
>
<SelectTrigger id="theme" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
)
const integrationsPage = (
<>
<NetSuiteConnectionStatus />
<SyncControls />
</>
)
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title="Settings"
description="Manage your app preferences."
className="sm:max-w-xl"
>
<ResponsiveDialogBody
pages={[generalPage, notificationsPage, appearancePage, integrationsPage]}
>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full inline-flex justify-start overflow-x-auto">
<TabsTrigger value="general" className="text-xs sm:text-sm shrink-0">
General
</TabsTrigger>
<TabsTrigger value="notifications" className="text-xs sm:text-sm shrink-0">
Notifications Notifications
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="appearance">Appearance</TabsTrigger> <TabsTrigger value="appearance" className="text-xs sm:text-sm shrink-0">
Appearance
</TabsTrigger>
<TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0">
Integrations
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="general" className="space-y-4 pt-4"> <TabsContent value="general" className="space-y-3 pt-3">
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="timezone">Timezone</Label> <Label htmlFor="timezone" className="text-xs">
Timezone
</Label>
<Select value={timezone} onValueChange={setTimezone}> <Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone" className="w-full"> <SelectTrigger id="timezone" className="w-full h-9">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -91,64 +213,69 @@ export function SettingsModal({
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div className="min-w-0 flex-1">
<Label>Weekly digest</Label> <Label className="text-xs">Weekly digest</Label>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-xs">
Receive a summary of activity each week. Receive a summary of activity each week.
</p> </p>
</div> </div>
<Switch <Switch
checked={weeklyDigest} checked={weeklyDigest}
onCheckedChange={setWeeklyDigest} onCheckedChange={setWeeklyDigest}
className="shrink-0"
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent <TabsContent
value="notifications" value="notifications"
className="space-y-4 pt-4" className="space-y-3 pt-3"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div className="min-w-0 flex-1">
<Label>Email notifications</Label> <Label className="text-xs">Email notifications</Label>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-xs">
Get notified about project updates via email. Get notified about project updates via email.
</p> </p>
</div> </div>
<Switch <Switch
checked={emailNotifs} checked={emailNotifs}
onCheckedChange={setEmailNotifs} onCheckedChange={setEmailNotifs}
className="shrink-0"
/> />
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div> <div className="min-w-0 flex-1">
<Label>Push notifications</Label> <Label className="text-xs">Push notifications</Label>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-xs">
Receive push notifications in your browser. Receive push notifications in your browser.
</p> </p>
</div> </div>
<Switch <Switch
checked={pushNotifs} checked={pushNotifs}
onCheckedChange={setPushNotifs} onCheckedChange={setPushNotifs}
className="shrink-0"
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent <TabsContent
value="appearance" value="appearance"
className="space-y-4 pt-4" className="space-y-3 pt-3"
> >
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="theme">Theme</Label> <Label htmlFor="theme" className="text-xs">
Theme
</Label>
<Select <Select
value={theme ?? "light"} value={theme ?? "light"}
onValueChange={setTheme} onValueChange={setTheme}
> >
<SelectTrigger id="theme" className="w-full"> <SelectTrigger id="theme" className="w-full h-9">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -159,8 +286,16 @@ export function SettingsModal({
</Select> </Select>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent
value="integrations"
className="space-y-3 pt-3"
>
<NetSuiteConnectionStatus />
<SyncControls />
</TabsContent>
</Tabs> </Tabs>
</DialogContent> </ResponsiveDialogBody>
</Dialog> </ResponsiveDialog>
) )
} }

View File

@ -4,6 +4,7 @@ import * as React from "react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { import {
IconLogout, IconLogout,
IconMenu2,
IconMoon, IconMoon,
IconSearch, IconSearch,
IconSun, IconSun,
@ -20,7 +21,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { SidebarTrigger } from "@/components/ui/sidebar" import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"
import { NotificationsPopover } from "@/components/notifications-popover" import { NotificationsPopover } from "@/components/notifications-popover"
import { useCommandMenu } from "@/components/command-menu-provider" import { useCommandMenu } from "@/components/command-menu-provider"
import { useFeedback } from "@/components/feedback-widget" import { useFeedback } from "@/components/feedback-widget"
@ -31,86 +32,142 @@ export function SiteHeader() {
const { open: openCommand } = useCommandMenu() const { open: openCommand } = useCommandMenu()
const { open: openFeedback } = useFeedback() const { open: openFeedback } = useFeedback()
const [accountOpen, setAccountOpen] = React.useState(false) const [accountOpen, setAccountOpen] = React.useState(false)
const { toggleSidebar } = useSidebar()
return ( return (
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-2 md:px-4"> <header className="sticky top-0 z-40 flex shrink-0 items-center bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<SidebarTrigger className="-ml-1" /> {/* mobile header: single unified pill */}
<div className="flex h-14 w-full items-center px-3 md:hidden">
<div <div
className="relative mx-auto hidden min-[480px]:block w-full max-w-md cursor-pointer" className="flex h-11 w-full items-center gap-2 rounded-full bg-muted/50 px-2.5 cursor-pointer"
onClick={openCommand} onClick={openCommand}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openCommand() if (e.key === "Enter" || e.key === " ") openCommand()
}} }}
> >
<IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" /> <button
<div className="bg-muted/50 border-input flex h-9 w-full items-center rounded-md border pl-9 pr-3 text-sm"> className="flex size-8 shrink-0 items-center justify-center rounded-full -ml-0.5 hover:bg-background/60"
<span className="text-muted-foreground flex-1"> onClick={(e) => {
e.stopPropagation()
toggleSidebar()
}}
aria-label="Open menu"
>
<IconMenu2 className="size-5 text-muted-foreground" />
</button>
<IconSearch className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground text-sm flex-1">
Search... Search...
</span> </span>
<kbd className="bg-muted text-muted-foreground pointer-events-none ml-2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs"> <DropdownMenu>
<span className="text-xs">&#x2318;</span>K <DropdownMenuTrigger asChild>
</kbd> <button
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => e.stopPropagation()}
>
<Avatar className="size-8 grayscale">
<AvatarImage src="/avatars/martine.jpg" alt="Martine Vogel" />
<AvatarFallback className="text-xs">MV</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">Martine Vogel</p>
<p className="text-muted-foreground text-xs">martine@compass.io</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setAccountOpen(true)}>
<IconUserCircle />
Account
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setTheme(theme === "dark" ? "light" : "dark")}>
<IconSun className="hidden dark:block" />
<IconMoon className="block dark:hidden" />
Toggle theme
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
{/* Mobile search button */} {/* desktop header: traditional layout */}
<Button <div className="hidden h-14 w-full items-center gap-2 border-b px-4 md:flex">
variant="ghost" <SidebarTrigger className="-ml-1" />
size="icon"
className="size-8 min-[480px]:hidden ml-auto"
onClick={openCommand}
>
<IconSearch className="size-4" />
</Button>
<div className="flex items-center gap-1"> <div
<Button className="relative mx-auto w-full max-w-md cursor-pointer"
variant="ghost" onClick={openCommand}
size="sm" role="button"
className="text-muted-foreground text-xs hidden sm:inline-flex" tabIndex={0}
onClick={openFeedback} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openCommand()
}}
> >
Feedback <IconSearch className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
</Button> <div className="bg-muted/50 border-input flex h-9 w-full items-center rounded-md border pl-9 pr-3 text-sm">
<NotificationsPopover /> <span className="text-muted-foreground flex-1">
<Button Search...
variant="ghost" </span>
size="icon" <kbd className="bg-muted text-muted-foreground pointer-events-none ml-2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border px-1.5 font-mono text-xs">
className="size-8" <span className="text-xs">&#x2318;</span>K
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} </kbd>
> </div>
<IconSun className="size-4 hidden dark:block" /> </div>
<IconMoon className="size-4 block dark:hidden" />
</Button> <div className="flex shrink-0 items-center gap-1">
<DropdownMenu> <Button
<DropdownMenuTrigger asChild> variant="ghost"
<button className="ml-1 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"> size="sm"
<Avatar className="size-7 grayscale"> className="text-muted-foreground text-xs"
<AvatarImage src="/avatars/martine.jpg" alt="Martine Vogel" /> onClick={openFeedback}
<AvatarFallback className="text-xs">MV</AvatarFallback> >
</Avatar> Feedback
</button> </Button>
</DropdownMenuTrigger> <NotificationsPopover />
<DropdownMenuContent align="end" className="w-48"> <Button
<DropdownMenuLabel className="font-normal"> variant="ghost"
<p className="text-sm font-medium">Martine Vogel</p> size="icon"
<p className="text-muted-foreground text-xs">martine@compass.io</p> className="size-8"
</DropdownMenuLabel> onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
<DropdownMenuSeparator /> >
<DropdownMenuItem onSelect={() => setAccountOpen(true)}> <IconSun className="size-4 hidden dark:block" />
<IconUserCircle /> <IconMoon className="size-4 block dark:hidden" />
Account </Button>
</DropdownMenuItem> <DropdownMenu>
<DropdownMenuSeparator /> <DropdownMenuTrigger asChild>
<DropdownMenuItem> <button className="ml-1 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
<IconLogout /> <Avatar className="size-7 grayscale">
Log out <AvatarImage src="/avatars/martine.jpg" alt="Martine Vogel" />
</DropdownMenuItem> <AvatarFallback className="text-xs">MV</AvatarFallback>
</DropdownMenuContent> </Avatar>
</DropdownMenu> </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">Martine Vogel</p>
<p className="text-muted-foreground text-xs">martine@compass.io</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setAccountOpen(true)}>
<IconUserCircle />
Account
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} /> <AccountModal open={accountOpen} onOpenChange={setAccountOpen} />

View File

@ -0,0 +1,59 @@
"use client"
import { useState, useRef, useCallback } from "react"
const TRIGGER_THRESHOLD = 80
const MAX_PULL = 120
interface PullToRefreshResult {
isRefreshing: boolean
pullDistance: number
handlers: {
onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: () => void
}
}
export function usePullToRefresh(
onRefresh: () => Promise<void>
): PullToRefreshResult {
const [isRefreshing, setIsRefreshing] = useState(false)
const [pullDistance, setPullDistance] = useState(0)
const startY = useRef<number | null>(null)
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (window.scrollY === 0) {
startY.current = e.touches[0].clientY
}
}, [])
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (startY.current === null || window.scrollY > 0) return
const currentY = e.touches[0].clientY
const distance = Math.max(
0,
Math.min(currentY - startY.current, MAX_PULL)
)
setPullDistance(distance)
}, [])
const onTouchEnd = useCallback(async () => {
if (pullDistance > TRIGGER_THRESHOLD) {
setIsRefreshing(true)
try {
await onRefresh()
} finally {
setIsRefreshing(false)
}
}
setPullDistance(0)
startY.current = null
}, [pullDistance, onRefresh])
return {
isRefreshing,
pullDistance,
handlers: { onTouchStart, onTouchMove, onTouchEnd },
}
}