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 { SiteHeader } from "@/components/site-header"
import { MobileBottomNav } from "@/components/mobile-bottom-nav"
import { CommandMenuProvider } from "@/components/command-menu-provider"
import { SettingsProvider } from "@/components/settings-provider"
import { FeedbackWidget } from "@/components/feedback-widget"
@ -9,6 +10,7 @@ import {
SidebarProvider,
} from "@/components/ui/sidebar"
import { getProjects } from "@/app/actions/projects"
import { ProjectListProvider } from "@/components/project-list-provider"
export default async function DashboardLayout({
children,
@ -19,6 +21,7 @@ export default async function DashboardLayout({
return (
<SettingsProvider>
<ProjectListProvider projects={projectList}>
<CommandMenuProvider>
<SidebarProvider
defaultOpen={false}
@ -33,19 +36,21 @@ export default async function DashboardLayout({
<FeedbackWidget>
<SidebarInset className="overflow-hidden">
<SiteHeader />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div className="@container/main flex flex-1 flex-col">
<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 min-w-0">
{children}
</div>
</div>
</SidebarInset>
</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
</p>
<Toaster position="bottom-right" />
</SidebarProvider>
</CommandMenuProvider>
</ProjectListProvider>
</SettingsProvider>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@
import * as React from "react"
import { CommandMenu } from "@/components/command-menu"
import { MobileSearch } from "@/components/mobile-search"
import { useIsMobile } from "@/hooks/use-mobile"
const CommandMenuContext = React.createContext<{
open: () => void
@ -16,17 +18,32 @@ export function CommandMenuProvider({
}: {
children: React.ReactNode
}) {
const isMobile = useIsMobile()
const [isOpen, setIsOpen] = React.useState(false)
const [mobileSearchOpen, setMobileSearchOpen] =
React.useState(false)
const value = React.useMemo(
() => ({ open: () => setIsOpen(true) }),
[]
() => ({
open: () => {
if (isMobile) {
setMobileSearchOpen(true)
} else {
setIsOpen(true)
}
},
}),
[isMobile]
)
return (
<CommandMenuContext.Provider value={value}>
{children}
<CommandMenu open={isOpen} setOpen={setIsOpen} />
<MobileSearch
open={mobileSearchOpen}
setOpen={setMobileSearchOpen}
/>
</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",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
meta: { className: "hidden md:table-cell" },
},
{
id: "select",
@ -180,12 +181,13 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<div className="min-w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
meta: { className: "hidden lg:table-cell" },
},
{
accessorKey: "status",
@ -250,6 +252,7 @@ const columns: ColumnDef<z.infer<typeof schema>>[] = [
/>
</form>
),
meta: { className: "hidden lg:table-cell" },
},
{
accessorKey: "reviewer",
@ -327,11 +330,14 @@ function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
{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>
)
}
@ -479,56 +485,63 @@ export function DataTable({
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
<div className="overflow-x-auto -mx-4 md:mx-0 rounded-lg border">
<div className="inline-block min-w-full align-middle">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as { className?: string } | undefined
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={meta?.className}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
</div>
<div className="flex items-center justify-between px-4">
<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
onClick={() => setDialogOpen(true)}
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" />
<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>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>
<DialogContent className="max-w-[calc(100%-2rem)] sm:max-w-md p-4 sm:p-6">
<DialogHeader className="space-y-1.5">
<DialogTitle className="text-base sm:text-lg">Send Feedback</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Report a bug, request a feature, or ask a question.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="feedback-type">Type</Label>
<form onSubmit={handleSubmit} className="grid gap-3 sm:gap-4">
<div className="grid gap-1.5">
<Label htmlFor="feedback-type" className="text-xs sm:text-sm">Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="feedback-type">
<SelectTrigger id="feedback-type" className="h-9">
<SelectValue placeholder="Select type..." />
</SelectTrigger>
<SelectContent>
@ -133,45 +133,48 @@ export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="feedback-message">Message</Label>
<div className="grid gap-1.5">
<Label htmlFor="feedback-message" className="text-xs sm:text-sm">Message</Label>
<Textarea
id="feedback-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your feedback..."
maxLength={2000}
rows={4}
rows={3}
required
className="text-sm resize-none"
/>
<p className="text-xs text-muted-foreground text-right">
{message.length}/2000
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="feedback-name">Name (optional)</Label>
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
<div className="grid gap-1.5">
<Label htmlFor="feedback-name" className="text-xs sm:text-sm">Name (optional)</Label>
<Input
id="feedback-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="h-9 text-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="feedback-email">Email (optional)</Label>
<div className="grid gap-1.5">
<Label htmlFor="feedback-email" className="text-xs sm:text-sm">Email (optional)</Label>
<Input
id="feedback-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="h-9 text-sm"
/>
</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"}
</Button>
</form>

View File

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

View File

@ -32,10 +32,10 @@ export function FileGrid({
const regularFiles = files.filter((f) => f.type !== "folder")
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6">
{folders.length > 0 && (
<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
</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">
@ -53,10 +53,10 @@ export function FileGrid({
)}
{regularFiles.length > 0 && (
<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
</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) => (
<FileContextMenu key={file.id} file={file} onRename={onRename} onMove={onMove}>
<FileCard

View File

@ -42,7 +42,7 @@ export const FolderCard = forwardRef<
<div
ref={ref}
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",
selected && "border-primary ring-2 ring-primary/20"
)}
@ -50,8 +50,8 @@ export const FolderCard = forwardRef<
onDoubleClick={handleDoubleClick}
{...props}
>
<FileIcon type="folder" size={22} />
<span className="text-sm font-medium truncate flex-1">{file.name}</span>
<FileIcon type="folder" size={22} className="shrink-0" />
<span className="text-sm font-medium line-clamp-2 flex-1 break-words">{file.name}</span>
{file.shared && (
<IconUsers size={14} className="text-muted-foreground shrink-0" />
)}
@ -98,25 +98,22 @@ export const FileCard = forwardRef<
>
<div
className={cn(
"flex items-center justify-center h-32",
"flex items-center justify-center h-20 sm:h-24",
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 className="flex items-center gap-2.5 px-3 py-2.5 border-t">
<FileIcon type={file.type} size={16} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
<div className="flex flex-col gap-1 px-2.5 py-2.5 border-t">
<p className="text-sm font-medium line-clamp-2 break-words leading-snug">{file.name}</p>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground truncate">
{formatRelativeDate(file.modifiedAt)}
{file.shared && " · Shared"}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
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"
)}
onClick={(e) => {
@ -130,12 +127,6 @@ export const FileCard = forwardRef<
<IconStar size={14} className="text-muted-foreground hover:text-amber-400" />
)}
</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>

View File

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

View File

@ -53,13 +53,12 @@ export function FileToolbar({
}
return (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5">
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex items-center gap-2 flex-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<IconPlus size={16} />
<span className="hidden sm:inline">New</span>
<Button size="sm" variant="outline" className="h-10 sm:h-9">
<IconPlus size={18} className="sm:size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
@ -87,71 +86,68 @@ export function FileToolbar({
</DropdownMenuItem>
</DropdownMenuContent>
</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 className="flex-1" />
<div
className={`relative transition-all duration-200 ${
searchFocused ? "w-48 sm:w-64" : "w-32 sm:w-44"
}`}
>
<IconSearch
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>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost" className="h-10 sm:h-9">
{state.sortDirection === "asc" ? (
<IconSortAscending size={18} className="sm:size-4" />
) : (
<IconSortDescending size={18} className="sm:size-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</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>
))}
</DropdownMenuContent>
</DropdownMenu>
<ToggleGroup
type="single"
value={state.viewMode}
onValueChange={(v) => {
if (v) dispatch({ type: "SET_VIEW_MODE", payload: v as ViewMode })
}}
size="sm"
>
<ToggleGroupItem value="grid" aria-label="Grid view">
<IconLayoutGrid size={16} />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view">
<IconList size={16} />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="single"
value={state.viewMode}
onValueChange={(v) => {
if (v) dispatch({ type: "SET_VIEW_MODE", payload: v as ViewMode })
}}
variant="outline"
size="sm"
className="h-10 sm:h-9"
>
<ToggleGroupItem value="grid" aria-label="Grid view" className="h-10 w-10 sm:h-9 sm:w-9">
<IconLayoutGrid size={18} className="sm:size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view" className="h-10 w-10 sm:h-9 sm:w-9">
<IconList size={18} className="sm:size-4" />
</ToggleGroupItem>
</ToggleGroup>
</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"
import { useState } from "react"
import {
IconBell,
IconMessageCircle,
@ -9,11 +10,20 @@ import {
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { BadgeIndicator } from "@/components/ui/badge-indicator"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { useIsMobile } from "@/hooks/use-mobile"
const notifications = [
{
@ -42,43 +52,71 @@ const notifications = [
},
]
export function NotificationsPopover() {
function NotificationsList() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative size-8">
<IconBell className="size-4" />
<span className="bg-destructive absolute top-1 right-1 size-1.5 rounded-full" />
<>
<div className="max-h-[60vh] overflow-y-auto">
{notifications.map((item, index) => (
<div
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>
</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">
<div className="border-b px-4 py-3">
<p className="text-sm font-medium">Notifications</p>
</div>
<div className="max-h-72 overflow-y-auto">
{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>
<NotificationsList />
</PopoverContent>
</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 (
<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
placeholder="Baseline name..."
value={baselineName}
onChange={(e) => setBaselineName(e.target.value)}
className="max-w-[250px]"
className="flex-1 sm:max-w-[250px]"
/>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !baselineName.trim()}
className="whitespace-nowrap"
>
Save Baseline
</Button>

View File

@ -104,19 +104,20 @@ export function ScheduleCalendarView({
return (
<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">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
className="h-9"
>
Today
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
className="size-9"
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
>
<IconChevronLeft className="size-4" />
@ -124,17 +125,17 @@ export function ScheduleCalendarView({
<Button
variant="ghost"
size="icon"
className="size-7"
className="size-9"
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
>
<IconChevronRight className="size-4" />
</Button>
<h2 className="text-lg font-medium">
{format(currentDate, "MMMM, yyyy")}
<h2 className="text-base sm:text-lg font-medium whitespace-nowrap">
{format(currentDate, "MMMM yyyy")}
</h2>
</div>
<Select defaultValue="month">
<SelectTrigger className="h-7 w-[100px] text-xs">
<SelectTrigger className="h-9 w-28 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -174,15 +175,15 @@ export function ScheduleCalendarView({
return (
<div
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" : ""
} ${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
className={`text-xs ${
className={`text-xs shrink-0 ${
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
? "text-foreground"
: "text-muted-foreground"
@ -191,8 +192,9 @@ export function ScheduleCalendarView({
{format(day, "d")}
</span>
{isNonWork && (
<span className="text-[9px] text-muted-foreground">
Non-workday
<span className="text-[8px] sm:text-[9px] text-muted-foreground truncate ml-1">
<span className="hidden sm:inline">Non-workday</span>
<span className="sm:hidden">Off</span>
</span>
)}
</div>
@ -200,26 +202,26 @@ export function ScheduleCalendarView({
{visibleTasks.map((task) => (
<div
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}
>
{task.title}
{task.title.length > 15 ? `${task.title.slice(0, 12)}...` : task.title}
</div>
))}
{!expanded && overflow > 0 && (
<button
className="text-[9px] text-primary hover:underline"
className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
+{overflow} more
+{overflow}
</button>
)}
{expanded && dayTasks.length > MAX_VISIBLE_TASKS && (
<button
className="text-[9px] text-primary hover:underline"
className="text-[9px] sm:text-[10px] text-primary hover:underline"
onClick={() => toggleExpand(dateKey)}
>
Show less
Less
</button>
)}
</div>

View File

@ -8,6 +8,11 @@ import {
} from "@/components/ui/resizable"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
@ -24,9 +29,15 @@ import {
IconUsers,
IconZoomIn,
IconZoomOut,
IconPointer,
IconHandGrab,
} 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 { TaskFormDialog } from "./task-form-dialog"
import {
@ -58,6 +69,7 @@ export function ScheduleGanttView({
dependencies,
}: ScheduleGanttViewProps) {
const router = useRouter()
const isMobile = useIsMobile()
const [viewMode, setViewMode] = useState<ViewMode>("Week")
const [phaseGrouping, setPhaseGrouping] = useState<"off" | "grouped">("off")
const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>(
@ -68,8 +80,9 @@ export function ScheduleGanttView({
const [editingTask, setEditingTask] = useState<ScheduleTaskData | null>(
null
)
const [mobileView, setMobileView] = useState<"tasks" | "chart">("chart")
const [panMode, setPanMode] = useState(false)
const [panMode] = useState(false)
const defaultWidths: Record<ViewMode, number> = {
Day: 38, Week: 140, Month: 120,
@ -152,215 +165,359 @@ export function ScheduleGanttView({
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
{(["Day", "Week", "Month"] as ViewMode[]).map((mode) => (
<Button
key={mode}
size="sm"
variant={viewMode === mode ? "default" : "outline"}
onClick={() => handleViewModeChange(mode)}
<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">
{isMobile && (
<Select
value={mobileView}
onValueChange={(val) => setMobileView(val as "tasks" | "chart")}
>
{mode}
</Button>
))}
<SelectTrigger className="h-9 w-24">
<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
variant="outline"
size="sm"
onClick={scrollToToday}
className="h-9 px-3"
>
Today
</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 className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Switch
checked={isGrouped}
onCheckedChange={(checked) => {
setPhaseGrouping(checked ? "grouped" : "off")
if (!checked) setCollapsedPhases(new Set())
}}
className="scale-75"
/>
<span className="text-xs text-muted-foreground">
Phases
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="size-9"
onClick={() => handleZoom("out")}
title="Zoom out"
>
<IconZoomOut className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
className="size-9"
onClick={() => handleZoom("in")}
title="Zoom in"
>
<IconZoomIn className="size-4" />
</Button>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={showCriticalPath}
onCheckedChange={setShowCriticalPath}
className="scale-75"
/>
<span className="text-xs text-muted-foreground">
Critical Path
</span>
</div>
<Button
variant={phaseGrouping === "grouped" && collapsedPhases.size > 0 ? "default" : "outline"}
size="sm"
onClick={toggleClientView}
>
<IconUsers className="size-3.5 mr-1" />
Client View
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<IconUsers className="size-4 sm:mr-2" />
<span className="hidden sm:inline">Options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<div className="px-2 py-1.5">
<div className="flex items-center justify-between mb-2">
<span className="text-sm">Group by Phases</span>
<Switch
checked={isGrouped}
onCheckedChange={(checked) => {
setPhaseGrouping(checked ? "grouped" : "off")
if (!checked) setCollapsedPhases(new Set())
}}
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>
<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)}
>
<TableCell
colSpan={collapsed ? 4 : 1}
className="text-xs py-1.5 font-medium"
{isMobile ? (
<div className="flex flex-col flex-1 min-h-0">
{mobileView === "tasks" ? (
<div className="border rounded-md flex-1 min-h-0 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)}
>
<span className="flex items-center gap-1">
{collapsed
? <IconChevronRight className="size-3.5" />
: <IconChevronDown className="size-3.5" />}
{group.label}
<span className="text-muted-foreground font-normal ml-1">
({group.tasks.length})
</span>
{collapsed && (
<span className="text-muted-foreground font-normal ml-auto text-[10px]">
{group.startDate.slice(5)} {group.endDate.slice(5)}
<TableCell
colSpan={collapsed ? 4 : 1}
className="text-xs py-1.5 font-medium"
>
<span className="flex items-center gap-1">
{collapsed
? <IconChevronRight className="size-3.5" />
: <IconChevronDown className="size-3.5" />}
{group.label}
<span className="text-muted-foreground font-normal ml-1">
({group.tasks.length})
</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>
</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" />
</>
)}
<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>
)
}
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)
}}
})}
<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>
) : (
<div className="border rounded-md flex-1 min-h-0 overflow-hidden p-2">
<GanttChart
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" />
</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>
<TableCell
colSpan={collapsed ? 4 : 1}
className="text-xs py-1.5 font-medium"
>
<span className="flex items-center gap-1">
{collapsed
? <IconChevronRight className="size-3.5" />
: <IconChevronDown className="size-3.5" />}
{group.label}
<span className="text-muted-foreground font-normal ml-1">
({group.tasks.length})
</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>
)
}
<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}>
<div className="h-full overflow-hidden p-2">
<GanttChart
tasks={frappeTasks}
viewMode={viewMode}
columnWidth={columnWidth}
panMode={panMode}
onDateChange={handleDateChange}
onZoom={handleZoom}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={70} minSize={40}>
<div className="h-full overflow-hidden p-2">
<GanttChart
tasks={frappeTasks}
viewMode={viewMode}
columnWidth={columnWidth}
panMode={panMode}
onDateChange={handleDateChange}
onZoom={handleZoom}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
)}
<TaskFormDialog
open={taskFormOpen}

View File

@ -192,7 +192,7 @@ export function ScheduleListView({
cell: ({ row }) => (
<div className="flex items-center gap-2">
<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}
</span>
</div>
@ -205,6 +205,7 @@ export function ScheduleListView({
<ProgressRing percent={row.original.percentComplete} />
),
size: 70,
meta: { className: "hidden sm:table-cell" },
},
{
accessorKey: "phase",
@ -214,6 +215,7 @@ export function ScheduleListView({
{row.original.phase}
</span>
),
meta: { className: "hidden lg:table-cell" },
},
{
id: "duration",
@ -224,6 +226,7 @@ export function ScheduleListView({
</span>
),
size: 80,
meta: { className: "hidden md:table-cell" },
},
{
accessorKey: "startDate",
@ -309,50 +312,58 @@ export function ScheduleListView({
</Button>
</div>
<div className="rounded-md border flex-1 overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</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>
))}
<div className="rounded-md border flex-1 overflow-x-auto -mx-2 sm:mx-0">
<div className="inline-block min-w-full align-middle">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as { className?: string } | undefined
return (
<TableHead key={header.id} className={meta?.className}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))
)}
</TableBody>
</Table>
))}
</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) => {
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 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,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
import {
IconSettings,
@ -15,6 +17,9 @@ import {
IconFilter,
IconPlus,
IconDots,
IconDownload,
IconUpload,
IconPrinter,
} from "@tabler/icons-react"
interface ScheduleToolbarProps {
@ -25,45 +30,58 @@ export function ScheduleToolbar({ onNewItem }: ScheduleToolbarProps) {
const [offlineMode, setOfflineMode] = useState(false)
return (
<div className="flex flex-wrap items-center justify-between gap-2 py-1.5 border-b mb-2">
<div className="flex items-center gap-1 sm:gap-3">
<Button variant="ghost" size="icon" className="size-8">
<IconSettings className="size-4" />
</Button>
<Button variant="ghost" size="icon" className="size-8">
<IconHistory className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Switch
checked={offlineMode}
onCheckedChange={setOfflineMode}
className="scale-75"
/>
<span className="text-xs text-muted-foreground hidden sm:inline">
Schedule Offline
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs h-8">
<IconDots className="size-4 sm:mr-1" />
<span className="hidden sm:inline">More Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Export Schedule</DropdownMenuItem>
<DropdownMenuItem>Import Schedule</DropdownMenuItem>
<DropdownMenuItem>Print</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="sm" className="text-xs h-8">
<IconFilter className="size-4 sm:mr-1" />
<span className="hidden sm:inline">Filter</span>
</Button>
</div>
<Button size="sm" onClick={onNewItem}>
<IconPlus className="size-4 sm:mr-1" />
<span className="hidden sm:inline">New Schedule Item</span>
<div className="flex items-center justify-between gap-2 py-2 border-b mb-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<IconDots className="size-4 mr-2" />
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem>
<IconSettings className="size-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuItem>
<IconHistory className="size-4 mr-2" />
Version History
</DropdownMenuItem>
<DropdownMenuItem>
<IconFilter className="size-4 mr-2" />
Filter Tasks
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground px-2 py-1">
Import & Export
</DropdownMenuLabel>
<DropdownMenuItem>
<IconDownload className="size-4 mr-2" />
Export Schedule
</DropdownMenuItem>
<DropdownMenuItem>
<IconUpload className="size-4 mr-2" />
Import Schedule
</DropdownMenuItem>
<DropdownMenuItem>
<IconPrinter className="size-4 mr-2" />
Print
</DropdownMenuItem>
<DropdownMenuSeparator />
<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>
</div>
)

View File

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

View File

@ -1,15 +1,15 @@
"use client"
import * as React from "react"
import { useEffect, useMemo } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
import {
Form,
FormControl,
@ -144,196 +144,206 @@ export function TaskFormDialog({
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Task" : "New Task"}
</DialogTitle>
</DialogHeader>
const page1 = (
<>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Title</FormLabel>
<FormControl>
<Input placeholder="Task title" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Task title" {...field} />
</FormControl>
<FormMessage />
</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}
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Start Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className="w-full h-9 justify-start text-left font-normal text-sm"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select phase" />
</SelectTrigger>
</FormControl>
<SelectContent>
{phases.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<IconCalendar className="size-3.5 mr-2 text-muted-foreground" />
{field.value
? format(parseISO(field.value), "MMM d, yyyy")
: "Pick 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="percentComplete"
render={({ field }) => (
<FormItem>
<FormLabel>
Complete: {field.value}%
</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="workdays"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Workdays</FormLabel>
<FormControl>
<Input
type="number"
min={1}
className="h-9"
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value) || 0)
}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)
<FormField
control={form.control}
name="assignedTo"
render={({ field }) => (
<FormItem>
<FormLabel>Assigned To</FormLabel>
<FormControl>
<Input placeholder="Person name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
const page2 = (
<>
<FormField
control={form.control}
name="phase"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Phase</FormLabel>
<Select
onValueChange={field.onChange}
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
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">Milestone</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name="assignedTo"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Assigned To</FormLabel>
<FormControl>
<Input placeholder="Person name" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">
{isEditing ? "Save" : "Create"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
const page3 = (
<>
{calculatedEnd && (
<p className="text-xs text-muted-foreground">
End date: <strong>{calculatedEnd}</strong>
</p>
)}
<FormField
control={form.control}
name="percentComplete"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
Complete: {field.value}%
</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 { zodResolver } from "@hookform/resolvers/zod"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
import {
Form,
FormControl,
@ -145,39 +144,37 @@ export function WorkdayExceptionFormDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Exception" : "New Workday Exception"}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={isEditing ? "Edit Exception" : "New Workday Exception"}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
<ResponsiveDialogBody>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormLabel className="text-xs">Title</FormLabel>
<FormControl>
<Input placeholder="e.g. Christmas Day" {...field} />
<Input placeholder="e.g. Christmas Day" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date</FormLabel>
<FormLabel className="text-xs">Start Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
<Input type="date" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -188,9 +185,9 @@ export function WorkdayExceptionFormDialog({
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>End Date</FormLabel>
<FormLabel className="text-xs">End Date</FormLabel>
<FormControl>
<Input type="date" {...field} />
<Input type="date" className="h-9" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -198,19 +195,19 @@ export function WorkdayExceptionFormDialog({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormLabel className="text-xs">Category</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
</FormControl>
@ -231,13 +228,13 @@ export function WorkdayExceptionFormDialog({
name="recurrence"
render={({ field }) => (
<FormItem>
<FormLabel>Recurrence</FormLabel>
<FormLabel className="text-xs">Recurrence</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
</FormControl>
@ -260,12 +257,12 @@ export function WorkdayExceptionFormDialog({
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormLabel className="text-xs">Notes <span className="text-muted-foreground">(optional)</span></FormLabel>
<FormControl>
<Textarea
placeholder="Optional notes..."
className="resize-none"
rows={3}
className="resize-none text-sm"
rows={1}
{...field}
/>
</FormControl>
@ -274,21 +271,23 @@ export function WorkdayExceptionFormDialog({
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">
{isEditing ? "Save" : "Create"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</ResponsiveDialogBody>
<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

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

View File

@ -4,12 +4,9 @@ import * as React from "react"
import { useTheme } from "next-themes"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
ResponsiveDialog,
ResponsiveDialogBody,
} from "@/components/ui/responsive-dialog"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
@ -26,6 +23,8 @@ import {
TabsTrigger,
} from "@/components/ui/tabs"
import { Separator } from "@/components/ui/separator"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls"
export function SettingsModal({
open,
@ -40,30 +39,153 @@ export function SettingsModal({
const [weeklyDigest, setWeeklyDigest] = React.useState(false)
const [timezone, setTimezone] = React.useState("America/New_York")
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>
Manage your app preferences.
</DialogDescription>
</DialogHeader>
const generalPage = (
<>
<div className="space-y-1.5">
<Label htmlFor="timezone" className="text-xs">
Timezone
</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone" className="w-full h-9">
<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">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="notifications">
<Separator />
<div className="flex items-center justify-between gap-4">
<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
</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>
<TabsContent value="general" className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<TabsContent value="general" className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label htmlFor="timezone" className="text-xs">
Timezone
</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone" className="w-full">
<SelectTrigger id="timezone" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -91,64 +213,69 @@ export function SettingsModal({
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Weekly digest</Label>
<p className="text-muted-foreground text-sm">
<div className="flex items-center justify-between gap-4">
<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>
</TabsContent>
<TabsContent
value="notifications"
className="space-y-4 pt-4"
className="space-y-3 pt-3"
>
<div className="flex items-center justify-between">
<div>
<Label>Email notifications</Label>
<p className="text-muted-foreground text-sm">
<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">
<div>
<Label>Push notifications</Label>
<p className="text-muted-foreground text-sm">
<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>
</TabsContent>
<TabsContent
value="appearance"
className="space-y-4 pt-4"
className="space-y-3 pt-3"
>
<div className="space-y-2">
<Label htmlFor="theme">Theme</Label>
<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">
<SelectTrigger id="theme" className="w-full h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -159,8 +286,16 @@ export function SettingsModal({
</Select>
</div>
</TabsContent>
<TabsContent
value="integrations"
className="space-y-3 pt-3"
>
<NetSuiteConnectionStatus />
<SyncControls />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</ResponsiveDialogBody>
</ResponsiveDialog>
)
}

View File

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