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:
parent
fbd31b58ae
commit
d30decf723
24
public/manifest.json
Executable file
24
public/manifest.json
Executable 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
34
public/sw.js
Executable 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 · {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 · {completedPercent}% complete
|
||||
{project?.clientName && (
|
||||
<> · {project.clientName}</>
|
||||
)}
|
||||
{project?.projectManager && (
|
||||
<> · {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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
56
src/components/dashboard-tabs.tsx
Executable file
56
src/components/dashboard-tabs.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -140,6 +140,7 @@ export function FileBrowser({ path }: { path: string[] }) {
|
||||
onOpenChange={(open) => !open && setMoveFile(null)}
|
||||
file={moveFile}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
121
src/components/mobile-bottom-nav.tsx
Executable file
121
src/components/mobile-bottom-nav.tsx
Executable 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
123
src/components/mobile-fab.tsx
Executable 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
162
src/components/mobile-filter-bar.tsx
Executable file
162
src/components/mobile-filter-bar.tsx
Executable 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
130
src/components/mobile-project-switcher.tsx
Executable file
130
src/components/mobile-project-switcher.tsx
Executable 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
552
src/components/mobile-search.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
25
src/components/project-list-provider.tsx
Executable file
25
src/components/project-list-provider.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 "New Schedule Item" 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 "New Schedule Item" 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">
|
||||
|
||||
223
src/components/schedule/schedule-mobile-view.tsx
Executable file
223
src/components/schedule/schedule-mobile-view.tsx
Executable 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))}
|
||||
>
|
||||
←
|
||||
</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))}
|
||||
>
|
||||
→
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">⌘</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">⌘</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} />
|
||||
|
||||
59
src/hooks/use-pull-to-refresh.ts
Executable file
59
src/hooks/use-pull-to-refresh.ts
Executable 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 },
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user